Loading content...
In traditional TLS, only the server presents a certificate. The client verifies the server's identity, but the server has no cryptographic proof of who the client is. The client might authenticate via a bearer token, API key, or username/password—but these credentials can be stolen, replayed, or misused.
Mutual TLS (mTLS) changes this dynamic: both the client and server present certificates. Each party cryptographically proves its identity to the other. No secret tokens to steal. No passwords to phish. The identity is bound to a private key that never leaves the service.
In zero-trust architectures, mTLS is the foundation of service-to-service authentication. Rather than trusting any request that arrives from the "internal network," every service verifies the cryptographic identity of every caller. A compromised service cannot impersonate another—it lacks the private key.
This page covers everything you need to implement mTLS: how it works, where it fits in your architecture, the operational challenges, and practical implementation patterns from raw configuration to service mesh automation.
By the end of this page, you will understand how mTLS works, how to implement it in Kubernetes and other environments, how service meshes automate mTLS, best practices for certificate management in mTLS scenarios, and common pitfalls to avoid.
In standard TLS, only the server authenticates to the client. In mTLS, the handshake includes an additional step where the client also presents a certificate, and the server verifies it.
Standard TLS vs mTLS:
Standard TLS (One-Way):
Server knows: It's talking to someone Client knows: It's talking to the right server
Mutual TLS (Two-Way):
Server knows: It's talking to a verified service Client knows: It's talking to the right server
What makes mTLS powerful:
| Aspect | Token-Based Auth | mTLS |
|---|---|---|
| Credential type | String (JWT, API key) | Private key (never transmitted) |
| Replay attacks | Possible if token stolen | Impossible (key never leaves service) |
| Identity binding | Loose (token held by anyone) | Cryptographic (key pair per identity) |
| Revocation | Token blacklist, expiration | Certificate revocation, short validity |
| Lateral movement | Stolen token = full access | Need to steal private key from memory |
| Network position | Token accepted from anywhere | Can enforce source service identity |
SPIFFE (Secure Production Identity Framework for Everyone) standardizes service identity. Each service gets a SPIFFE ID (like 'spiffe://cluster.local/ns/production/sa/api-service') encoded in its certificate. SPIRE is the reference implementation. Service meshes like Istio implement SPIFFE-compatible identity.
There are multiple ways to implement mTLS in your architecture, each with different trade-offs in complexity, flexibility, and application impact.
| Pattern | App Changes | Operations | Identity Granularity | Best For |
|---|---|---|---|---|
| Application mTLS | High | Medium | Per-service | Small scale, full control needed |
| Sidecar/Mesh | None | High | Per-workload | Kubernetes microservices |
| Gateway mTLS | None | Low | Per-client | External API consumers |
| Network-Level | None | Medium | Per-host | Legacy, VM-based workloads |
For Kubernetes environments with many services, service mesh (Istio, Linkerd, Consul Connect) provides mTLS with minimal effort. The mesh handles certificate issuance, rotation, and enforcement. Applications remain unaware of TLS. However, if you have few services or non-Kubernetes workloads, application-level mTLS may be simpler overall.
When service meshes aren't an option or finer control is needed, implementing mTLS directly in applications provides maximum flexibility.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
package main import ( "crypto/tls" "crypto/x509" "io/ioutil" "log" "net/http") func main() { // Load server certificate and key serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key") if err != nil { log.Fatal(err) } // Load CA certificate to verify client certificates caCert, err := ioutil.ReadFile("ca.crt") if err != nil { log.Fatal(err) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) // Configure TLS with client certificate verification tlsConfig := &tls.Config{ Certificates: []tls.Certificate{serverCert}, ClientCAs: caCertPool, // RequireAndVerifyClientCert = mTLS // Clients MUST present valid certificate ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS13, } // Create HTTPS server server := &http.Server{ Addr: ":8443", TLSConfig: tlsConfig, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Access verified client certificate if len(r.TLS.PeerCertificates) > 0 { clientCert := r.TLS.PeerCertificates[0] log.Printf("Request from: %s", clientCert.Subject.CommonName) // Use certificate subject for authorization // e.g., clientCert.Subject.CommonName == "allowed-service" } w.Write([]byte("Hello, authenticated client!")) }), } log.Println("Starting mTLS server on :8443") // Use ListenAndServeTLS with empty strings since certs are in TLSConfig log.Fatal(server.ListenAndServeTLS("", ""))}Authentication (proving identity) and authorization (granting access) are separate concerns. After mTLS verifies the client certificate, you still need to check if that identity is authorized for the requested action. Use the certificate's Common Name (CN), Subject Alternative Names (SAN), or SPIFFE ID to make authorization decisions.
Service meshes like Istio, Linkerd, and Consul Connect automate mTLS between all services in the mesh. This is the most operationally practical approach for Kubernetes microservices.
How mesh mTLS works:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
# Istio: Enable STRICT mTLS mesh-wideapiVersion: security.istio.io/v1beta1kind: PeerAuthenticationmetadata: name: default namespace: istio-system # Applies to entire meshspec: mtls: mode: STRICT # Reject non-mTLS traffic---# Namespace-level mTLS policyapiVersion: security.istio.io/v1beta1kind: PeerAuthenticationmetadata: name: production-mtls namespace: productionspec: mtls: mode: STRICT---# Allow specific workload to accept plaintext (migration)apiVersion: security.istio.io/v1beta1kind: PeerAuthenticationmetadata: name: legacy-service-permissive namespace: productionspec: selector: matchLabels: app: legacy-service mtls: mode: PERMISSIVE # Accept both mTLS and plaintext---# Authorization policy: Only payment-service can call ordersapiVersion: security.istio.io/v1beta1kind: AuthorizationPolicymetadata: name: orders-authz namespace: productionspec: selector: matchLabels: app: orders-service action: ALLOW rules: - from: - source: principals: - "cluster.local/ns/production/sa/payment-service" - "cluster.local/ns/production/sa/api-gateway" to: - operation: methods: ["GET", "POST"] paths: ["/orders/*"]When migrating to mTLS, start with PERMISSIVE mode which accepts both encrypted and plaintext traffic. Gradually move services to mTLS. Once all services are verified working with mTLS, switch to STRICT mode. This prevents outages during transition.
mTLS requires certificates for every service—potentially thousands of them. Managing this complexity requires automation and careful planning.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
# Internal CA for mTLS certificatesapiVersion: cert-manager.io/v1kind: ClusterIssuermetadata: name: internal-mtls-issuerspec: ca: secretName: internal-ca-secret---# Certificate for a service (referenced in pod)apiVersion: cert-manager.io/v1kind: Certificatemetadata: name: orders-service-mtls namespace: productionspec: secretName: orders-service-mtls-secret # Short validity for mTLS duration: 24h renewBefore: 4h issuerRef: name: internal-mtls-issuer kind: ClusterIssuer # Service identity commonName: orders-service # SPIFFE ID in SAN extension uris: - spiffe://cluster.local/ns/production/sa/orders-service # DNS names for service discovery dnsNames: - orders-service - orders-service.production - orders-service.production.svc.cluster.local # Enable for both client and server auth usages: - digital signature - key encipherment - server auth - client auth privateKey: algorithm: ECDSA size: 256---# Mount certificate in deploymentapiVersion: apps/v1kind: Deploymentmetadata: name: orders-service namespace: productionspec: template: spec: containers: - name: orders image: orders:v1 volumeMounts: - name: mtls-certs mountPath: /etc/mtls readOnly: true env: - name: TLS_CERT_FILE value: /etc/mtls/tls.crt - name: TLS_KEY_FILE value: /etc/mtls/tls.key - name: TLS_CA_FILE value: /etc/mtls/ca.crt volumes: - name: mtls-certs secret: secretName: orders-service-mtls-secretEvery service validating mTLS must trust the internal CA. Distribute the CA certificate via ConfigMap, mount it in all pods, or bake it into base images. Without proper CA trust, mTLS validation fails. With service meshes, this is handled automatically; for application-level mTLS, you must manage it.
mTLS provides authentication (who is calling), but you still need authorization (are they allowed to call this endpoint). The certificate's identity becomes the basis for access control decisions.
Authorization strategies:
1. Allowlist by certificate subject:
IF request.certificate.CN IN ["payment-service", "api-gateway"]
THEN allow
ELSE deny
2. SPIFFE ID-based policies:
IF request.certificate.URI == "spiffe://cluster.local/ns/production/sa/payment"
THEN allow
ELSE deny
3. Attribute-based (certificate extensions):
IF request.certificate.extensions["department"] == "finance"
AND request.certificate.extensions["environment"] == "production"
THEN allow
4. External policy engine (OPA, Styra):
query opa.example.com/v1/data/authz {
input: {
caller: request.certificate.subject,
resource: request.path,
action: request.method
}
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
package main import ( "net/http" "strings") // mTLS authorization middlewarefunc mtlsAuthzMiddleware(allowedServices []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // TLS connection info available after mTLS handshake if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { http.Error(w, "Client certificate required", http.StatusUnauthorized) return } clientCert := r.TLS.PeerCertificates[0] // Check Common Name against allowlist allowed := false for _, svc := range allowedServices { if clientCert.Subject.CommonName == svc { allowed = true break } } // Also check SPIFFE URI in SANs for _, uri := range clientCert.URIs { if strings.HasPrefix(uri.String(), "spiffe://cluster.local/ns/production/") { // Parse SPIFFE ID and check authorization spiffeID := uri.String() if isAuthorized(spiffeID, r.URL.Path, r.Method) { allowed = true break } } } if !allowed { http.Error(w, "Forbidden: service not authorized", http.StatusForbidden) return } // Add caller identity to context for logging/tracing r = r.WithContext(withCallerIdentity(r.Context(), clientCert.Subject.CommonName)) next.ServeHTTP(w, r) }) }} // Fine-grained SPIFFE-based authorizationfunc isAuthorized(spiffeID, path, method string) bool { // Example policy: only payment-service can POST to /orders if path == "/orders" && method == "POST" { return spiffeID == "spiffe://cluster.local/ns/production/sa/payment-service" } // Read access for any production service if method == "GET" { return strings.Contains(spiffeID, "/ns/production/") } return false}Even with mTLS authorization, maintain application-level checks. mTLS proves the caller is who they claim, but they might still be compromised. Rate limiting, input validation, and business logic authorization are still necessary. mTLS is one layer—not the only layer.
mTLS adds complexity and new failure modes. Here are common issues and how to diagnose them.
| Symptom | Likely Cause | Diagnosis | Solution |
|---|---|---|---|
| Connection refused | Server not requesting client cert | Check server ClientAuth setting | Set ClientAuth = RequireAndVerifyClientCert |
| Certificate unknown | CA not trusted | Check CA cert in trust store | Add CA cert to client RootCAs / server ClientCAs |
| Certificate expired | Cert validity / time skew | Check cert dates and system time | Renew cert; fix NTP synchronization |
| TLS handshake timeout | Firewall blocking | tcpdump / packet capture | Open required ports; check network policies |
| Certificate verify failed | Hostname mismatch | Check cert SANs vs connection hostname | Issue cert with correct DNS names / IPs |
| Bad certificate | Key/cert mismatch | Compare public key hashes | Regenerate matching key pair and certificate |
123456789101112131415161718192021222324252627282930313233
# Debug mTLS connection with OpenSSLopenssl s_client -connect server:8443 \ -cert client.crt \ -key client.key \ -CAfile ca.crt \ -state -debug # Check certificate validityopenssl x509 -in cert.pem -noout -dates # Verify certificate chainopenssl verify -CAfile ca.crt -untrusted intermediate.crt server.crt # Check if key matches certificateopenssl x509 -noout -modulus -in cert.crt | openssl md5openssl rsa -noout -modulus -in key.pem | openssl md5# (Output should match) # View certificate details including SANsopenssl x509 -in cert.pem -noout -text | grep -A1 "Subject Alternative Name" # Test mTLS with curlcurl -v \ --cert client.crt \ --key client.key \ --cacert ca.crt \ https://server:8443/api # Istio: Check proxy certificateistioctl proxy-config secret deployment/my-app -n production # Istio: Verify mTLS modeistioctl authn tls-check my-app.production productionService meshes provide rich mTLS observability. Istio's Kiali dashboard shows whether connections are mTLS-encrypted. Linkerd's viz extension displays secure vs insecure traffic. Use these tools to validate mTLS is working across your mesh before switching to STRICT mode.
We've covered mutual TLS comprehensively—from the protocol mechanics to production implementation patterns. Let's consolidate the key takeaways:
Module Complete:
You've now completed the TLS and Encryption in Transit module. You understand:
These skills form the foundation for securing all network communication in distributed systems—from external APIs to internal microservices.
Congratulations! You've mastered encryption in transit. You can now design TLS architectures, implement mTLS for service authentication, manage certificates at scale, and build systems where every network hop is cryptographically secured. These practices are the foundation of zero-trust networking.