Microservices Security: Service-to-Service Authentication with mTLS and Zero Trust

Microservice architectures replace monolithic trust with a network of service-to-service calls, each of which is a potential lateral movement path. This guide covers implementing mutual TLS (mTLS) for service identity, certificate lifecycle management, and zero-trust patterns for internal API authorization.

The Problem with Implicit Internal Trust

Traditional network security models trust traffic that originates inside the perimeter. In a microservice architecture, that assumption creates a broad lateral movement surface: an attacker who compromises any service can make API calls to any other service that accepts internal traffic without authentication.

Microservice deployments often start with a simple rule: “service A can call service B because they’re both in the private subnet.” As the system grows to 20, 50, or 200 services, that model becomes unmanageable and dangerous. Every service is an implicit trust boundary that doesn’t actually trust anything.

The answer is mutual TLS (mTLS) combined with per-request authorization — verifying identity at the transport layer and permissions at the application layer for every service-to-service call.

What mTLS Provides

Standard TLS authenticates the server to the client. Mutual TLS adds client authentication: both sides present certificates, and the connection is established only if both certificates are valid and trusted.

In a microservice context this means:

  • Identity verification: Service A knows it is actually talking to Service B, not a compromised service or attacker impersonating B
  • Mutual authentication: Both services confirm they are legitimate, authenticated members of the system
  • Encrypted channel: All inter-service communication is encrypted in transit, preventing eavesdropping within the cluster
  • Certificate-based revocation: If a service is compromised, its certificate can be revoked, immediately cutting it off from the rest of the system

What mTLS does not provide:

  • Authorization: mTLS confirms identity but not permission. Service A can prove it is Service A; that doesn’t mean it should be allowed to call the admin API on Service B.
  • Request integrity: mTLS secures the transport channel, not the content of requests. Input validation at the application layer is still required.

Service Identity: SPIFFE and SPIRE

The most principled framework for microservice identity is SPIFFE (Secure Production Identity Framework For Everyone). SPIFFE defines a standard for service identity across platforms: each workload receives a cryptographically verifiable SVID (SPIFFE Verifiable Identity Document) that encodes its identity as a URI (spiffe://trust-domain/path/to/service).

SPIRE (SPIFFE Runtime Environment) is the reference implementation that issues and rotates SVIDs. It runs as a daemon on each node, attests workload identity through multiple attestation mechanisms (node PSAT on Kubernetes, platform-specific attestors on VMs), and delivers X.509 certificates or JWT tokens for use in mTLS or API authentication.

Why SPIFFE matters over custom PKI:

  • Short-lived certificates (default 1 hour) with automatic rotation, eliminating certificate lifecycle management burden
  • Workload attestation based on platform identity (Kubernetes service account, EC2 instance role) rather than secret-based authentication
  • Federated trust across multiple clusters and clouds
  • Interoperable with Envoy, Istio, and other service meshes

Implementing mTLS with a Service Mesh

The lowest-friction path to cluster-wide mTLS is a service mesh (Istio, Linkerd, Consul Connect). The mesh injects a sidecar proxy (typically Envoy) alongside each service pod; the proxy handles TLS termination and mTLS establishment transparently, without requiring application code changes.

Istio mTLS Configuration

Enable strict mTLS cluster-wide (Istio):

# PeerAuthentication — enforce mTLS for all pods in the namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: production
spec:
  mtls:
    mode: STRICT  # Reject any non-mTLS traffic

In STRICT mode, Istio rejects connections that don’t present a valid client certificate. Connections from services without sidecars (legacy workloads) are blocked; migrate these before enabling strict mode cluster-wide.

Gradual rollout — PERMISSIVE mode:

# PERMISSIVE mode allows both mTLS and plain text — use during migration
spec:
  mtls:
    mode: PERMISSIVE

Istio telemetry identifies which connections are using mTLS and which are falling back to plaintext, letting you identify workloads that need sidecar injection before switching to STRICT.

AuthorizationPolicy — permission after identity:

# Allow only the frontend service to call the payment API
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: payment-api-allow
  namespace: production
spec:
  selector:
    matchLabels:
      app: payment-api
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/production/sa/frontend-service"]
    to:
    - operation:
        methods: ["POST"]
        paths: ["/payments/*"]

AuthorizationPolicy controls which services can call which endpoints, using the SPIFFE principal (Kubernetes service account) as the identity. This is the authorization layer on top of mTLS identity.

Implementing mTLS Without a Service Mesh

If a service mesh is not available, implement mTLS directly in your service code using your language’s TLS stack.

Go — Client and Server

// Server: load cert and CA, require client cert
func newTLSServer(certFile, keyFile, caFile string) (*tls.Config, error) {
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        return nil, err
    }
    
    caCert, err := os.ReadFile(caFile)
    if err != nil {
        return nil, err
    }
    
    caPool := x509.NewCertPool()
    if !caPool.AppendCertsFromPEM(caCert) {
        return nil, errors.New("failed to parse CA certificate")
    }
    
    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        ClientCAs:    caPool,
        ClientAuth:   tls.RequireAndVerifyClientCert,
        MinVersion:   tls.VersionTLS13,
    }, nil
}

// Client: load client cert and trust the server CA
func newTLSClient(certFile, keyFile, caFile string) (*tls.Config, error) {
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        return nil, err
    }
    
    caCert, err := os.ReadFile(caFile)
    if err != nil {
        return nil, err
    }
    
    caPool := x509.NewCertPool()
    caPool.AppendCertsFromPEM(caCert)
    
    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      caPool,
        MinVersion:   tls.VersionTLS13,
        // Verify server's SPIFFE URI SAN
        VerifyPeerCertificate: verifySpiffeID("spiffe://mycompany.example/payment-api"),
    }, nil
}

// Verify SPIFFE URI in peer certificate
func verifySpiffeID(expectedURI string) func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
    return func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
        for _, chain := range verifiedChains {
            for _, cert := range chain {
                for _, uri := range cert.URIs {
                    if uri.String() == expectedURI {
                        return nil
                    }
                }
            }
        }
        return fmt.Errorf("peer certificate does not contain expected SPIFFE URI: %s", expectedURI)
    }
}

Python — Using ssl Module

import ssl
import http.server
import urllib.request

def create_server_ssl_context(certfile, keyfile, cafile):
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
    ctx.load_verify_locations(cafile=cafile)
    ctx.verify_mode = ssl.CERT_REQUIRED  # Require client cert
    ctx.minimum_version = ssl.TLSVersion.TLSv1_3
    return ctx

def create_client_ssl_context(certfile, keyfile, cafile):
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.load_cert_chain(certfile=certfile, keyfile=keyfile)
    ctx.load_verify_locations(cafile=cafile)
    ctx.check_hostname = True
    ctx.verify_mode = ssl.CERT_REQUIRED
    ctx.minimum_version = ssl.TLSVersion.TLSv1_3
    return ctx

Certificate Lifecycle Management

Short-lived certificates dramatically reduce the impact of a certificate compromise — an attacker who obtains a certificate valid for 1 hour has a narrow exploitation window. Automate rotation:

cert-manager on Kubernetes:

# Certificate resource — cert-manager issues and auto-renews
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: payment-api-cert
  namespace: production
spec:
  secretName: payment-api-tls
  duration: 1h         # Short-lived
  renewBefore: 15m     # Rotate 15 minutes before expiry
  issuerRef:
    name: internal-ca
    kind: ClusterIssuer
  subject:
    organizations: ["mycompany"]
  uris:
    - spiffe://mycompany.example/ns/production/payment-api

With SPIRE, certificates are issued for a configurable duration (default 1 hour) and rotated automatically by the SPIRE agent — no manual certificate management required.

Authorization: What to Do After Identity Is Established

mTLS establishes who the caller is. Your application must still determine what they are allowed to do.

Patterns for internal service authorization:

1. Attribute-based access control (ABAC) using caller identity:

// Extract caller's SPIFFE identity from TLS connection
func extractCallerIdentity(r *http.Request) (string, error) {
    if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
        return "", errors.New("no client certificate")
    }
    cert := r.TLS.PeerCertificates[0]
    for _, uri := range cert.URIs {
        if strings.HasPrefix(uri.String(), "spiffe://") {
            return uri.String(), nil
        }
    }
    return "", errors.New("no SPIFFE URI in client certificate")
}

// Authorize action based on caller identity and requested resource
func authorizeServiceCall(callerID string, resource, action string) bool {
    policy := map[string]map[string][]string{
        "spiffe://mycompany.example/frontend-service": {
            "/payments": {"POST"},
        },
        "spiffe://mycompany.example/reporting-service": {
            "/payments": {"GET"},
        },
    }
    
    allowedActions, ok := policy[callerID][resource]
    if !ok {
        return false
    }
    for _, a := range allowedActions {
        if a == action {
            return true
        }
    }
    return false
}

2. Open Policy Agent (OPA) for centralised policy:

Evaluate service-to-service authorization against OPA policies, which can be updated without redeploying services. OPA integrates with Envoy via the External Authorization filter, enabling policy enforcement at the proxy layer without application code changes.

Common Mistakes

Using self-signed certificates without a CA chain. Applications that accept any valid certificate (not pinned to a specific CA) are vulnerable to impersonation by any actor who can create a self-signed cert. Always verify against a trust root controlled by your organisation.

Overly broad CA trust. If your service trusts your entire PKI root, any certificate issued by that root (including for different environments or purposes) can impersonate a service. Constrain CA trust to the specific issuing CA for your microservice identity — or better, use SPIFFE trust domains.

Not implementing authorization. mTLS is identity, not permission. A common error is treating successful mTLS establishment as authorization to perform any action. Add per-endpoint authorization checks using caller identity.

Long certificate lifetimes. Certificates valid for years defeat the key benefit of short-lived certificates for breach containment. Use 24 hours or less for service certificates; 1 hour with SPIRE is achievable with automated rotation.

Skipping revocation. Certificates need a revocation path (CRL or OCSP) for compromised services. Design your PKI to support rapid revocation, and test the revocation path under load before relying on it during an incident.