Most internal microservice communication is authenticated with a shared API key or a JWT signed by the same secret that signs user tokens. Both approaches have the same failure mode: the secret leaks, and every service that trusts it is compromised simultaneously. Mutual TLS eliminates the shared secret entirely. Each service proves its identity with an asymmetric key pair, and the private key never leaves the service. Compromise of one service does not affect others.
How mTLS differs from standard TLS
In standard TLS, only the server presents a certificate. The client verifies that the server's certificate is signed by a trusted CA and that the hostname matches the certificate's Subject Alternative Names. Authentication is one-way: the client knows who it is talking to, but the server knows nothing cryptographically verified about the client.
In mutual TLS, both parties present certificates. The server presents its certificate as usual. The client also presents a certificate, and the server verifies it against a configured CA. If the client certificate is invalid, not signed by the trusted CA, or revoked, the TLS handshake fails before a single application-layer byte is sent. The authentication happens at the transport layer, not in application code.
Setting up an internal CA
For service mesh authentication, you do not want to use a public CA — you control the entire trust boundary and there is no reason to pay for certificates or involve an external entity. An internal CA is the right model. The CA private key signs service certificates; services trust the CA certificate.
# Create an internal CA with a 10-year lifetime openssl genrsa -out ca.key 4096 openssl req -new -x509 -days 3650 -key ca.key \ -subj "/CN=Internal Services CA/O=Example Corp/C=US" \ -out ca.crt # Issue a certificate for the auth service (1-year lifetime for rotation) openssl genrsa -out auth-service.key 2048 openssl req -new -key auth-service.key \ -subj "/CN=auth-service/O=Example Corp" \ -out auth-service.csr # Sign with the CA — include SAN for the service's internal DNS name openssl x509 -req -days 365 -in auth-service.csr \ -CA ca.crt -CAkey ca.key -CAcreateserial \ -extfile <(echo "subjectAltName=DNS:auth-service,DNS:auth-service.default.svc.cluster.local") \ -out auth-service.crt # Verify the chain openssl verify -CAfile ca.crt auth-service.crt
Keep the CA private key in a secret management system (HashiCorp Vault, AWS KMS, or a hardware HSM for production). The CA certificate is not secret — all services need it to verify peer certificates, so it can be distributed freely.
Configuring Node.js for mTLS
import https from 'https';
import fs from 'fs';
import express from 'express';
const app = express();
// Server: require client certificate
const serverOptions = {
key: fs.readFileSync('/etc/certs/auth-service.key'),
cert: fs.readFileSync('/etc/certs/auth-service.crt'),
ca: fs.readFileSync('/etc/certs/ca.crt'),
requestCert: true, // ask the client to send a certificate
rejectUnauthorized: true, // reject if cert is missing or invalid
};
https.createServer(serverOptions, app).listen(443);
// Extract client identity from the verified certificate
app.use((req, res, next) => {
const cert = req.socket.getPeerCertificate();
if (!cert || !cert.subject) {
return res.status(401).json({ error: 'client_certificate_required' });
}
// cert.subject.CN is the service identity
req.clientServiceName = cert.subject.CN;
next();
});
// Client: send certificate when calling another service
const clientOptions = {
key: fs.readFileSync('/etc/certs/auth-service.key'),
cert: fs.readFileSync('/etc/certs/auth-service.crt'),
ca: fs.readFileSync('/etc/certs/ca.crt'),
};
const agent = new https.Agent(clientOptions);
const response = await fetch('https://billing-service/internal/charge', {
method: 'POST',
agent,
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
});
SPIFFE and SVIDs: a standard identity layer
Managing per-service certificates manually works at small scale but becomes unwieldy as the number of services grows. SPIFFE (Secure Production Identity Framework for Everyone) defines a standard for workload identity that sits on top of mTLS. Each workload receives a SPIFFE Verifiable Identity Document (SVID), which is an X.509 certificate with a URI SAN in the format spiffe://trust-domain/path/to/workload.
The SPIFFE Runtime Environment (SPIRE) implements SPIFFE. An agent running on each node attests to the workload's identity (using kernel-level attestation or Kubernetes pod metadata) and issues short-lived SVIDs via the SPIFFE Workload API. Services fetch their SVID from the local Unix socket — no static certificate files needed.
# SPIRE server: define a workload entry for the auth service
spire-server entry create \
-spiffeID spiffe://example.org/auth-service \
-parentID spiffe://example.org/node-agent \
-selector k8s:ns:production \
-selector k8s:sa:auth-service \
-ttl 3600
# In Go, fetch the SVID via Workload API
import "github.com/spiffe/go-spiffe/v2/workloadapi"
ctx := context.Background()
client, err := workloadapi.New(ctx, workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"))
if err != nil { log.Fatal(err) }
defer client.Close()
x509Source, err := workloadapi.NewX509Source(ctx, workloadapi.WithClient(client))
if err != nil { log.Fatal(err) }
tlsConfig := tlsconfig.MTLSServerConfig(x509Source, x509Source, tlsconfig.AuthorizeAny())
Certificate rotation in Kubernetes with cert-manager
cert-manager is the standard way to handle certificate lifecycle in Kubernetes. It integrates with internal CAs (via a ClusterIssuer pointing to a CA secret) and automates renewal before expiry.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: auth-service-tls
namespace: production
spec:
secretName: auth-service-tls-secret
duration: 720h # 30 days
renewBefore: 168h # renew 7 days before expiry
subject:
organizations: ["Example Corp"]
commonName: auth-service
dnsNames:
- auth-service
- auth-service.production.svc.cluster.local
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
With short certificate lifetimes (30 days) and automated renewal (7 days before expiry), you get near-automatic rotation without operator involvement. Services reload their certificates from the Kubernetes secret on a periodic basis or on file change notification.
Latency overhead
A full TLS 1.3 handshake between two services on the same local network adds roughly 0.3–1ms per new connection. With connection pooling (keep-alive connections), this cost is amortized across hundreds or thousands of requests, dropping to effectively zero for steady-state traffic. The incremental cost of the client certificate verification step in mTLS over standard TLS is negligible — it is a single signature verification operation.
The more significant cost comes from connection establishment rate during pod restarts or traffic spikes when the connection pool is cold. If your services establish connections frequently without pooling, the handshake cost accumulates. Measure with openssl s_time or a service-level p99 latency comparison between mTLS and plaintext to understand your actual overhead before deciding it is too expensive.