Mutual TLS (mTLS) flips the standard TLS model on its head. In regular TLS, only the server proves its identity — the client checks the server's certificate but the server accepts any client. With mTLS, both sides present certificates, and both sides verify. This is the foundation of zero trust networking: don't trust the network, verify every connection.
In standard TLS (what your browser uses for HTTPS), the authentication is one-directional:
Standard TLS:
Client -----> Server
"Show me your certificate"
Server: presents certificate signed by trusted CA
Client: verifies certificate, establishes encrypted connection
Server: accepts any client (no client identity verification)
Mutual TLS (mTLS):
Client <----> Server
"Show me your certificate" AND "Show me YOUR certificate"
Both sides verify. Connection only succeeds if BOTH certificates
are signed by a mutually trusted CA.
With mTLS:
In a microservices architecture, services call each other over the network. Without mTLS, any service (or attacker) that can reach the network can call any other service. mTLS ensures only authorized services can communicate:
Order Service ──mTLS──> Payment Service
(client cert: order-svc) (verifies: only order-svc can call me)
Instead of API keys (which can be leaked, shared, or brute-forced), issue client certificates to API consumers. The API gateway verifies the client cert before proxying the request.
Service meshes like Istio and Linkerd implement mTLS transparently. Every pod gets its own certificate (issued by the mesh's internal CA), and all pod-to-pod traffic is automatically encrypted and authenticated:
Pod A (Envoy sidecar) ──mTLS──> Pod B (Envoy sidecar)
Certificate: spiffe://cluster/ns/default/sa/service-a
IoT devices can be provisioned with unique client certificates during manufacturing. The server only accepts connections from devices with certificates signed by the device CA — no passwords to brute-force, no shared secrets to leak.
PostgreSQL, MySQL, and MongoDB all support mTLS. This ensures only authorized application servers can connect to the database, even if the database port is accidentally exposed.
# PostgreSQL with mTLS
psql "host=db.example.com \
sslmode=verify-full \
sslcert=/etc/ssl/client.crt \
sslkey=/etc/ssl/client.key \
sslrootcert=/etc/ssl/ca.crt"
The mTLS handshake extends the standard TLS handshake with client certificate exchange:
Client Server
| |
|--- ClientHello (TLS versions, ciphers) ->|
| |
|<- ServerHello + Server Certificate ------|
|<- CertificateRequest -------------------| <-- THIS IS THE mTLS PART
| |
|--- Client Certificate ----------------->| <-- Client proves identity
|--- CertificateVerify (signed) ---------->|
|--- Finished ---------------------------->|
| |
|<========= Encrypted traffic ==========>|
If the client doesn't have a valid certificate (or it's not signed by a trusted CA), the connection is rejected at the TLS layer — before any application code runs.
Let's build a complete mTLS setup: private CA, server certificate, client certificate, and Nginx configuration.
# Generate CA private key
openssl ecparam -genkey -name prime256v1 -out ca-key.pem
# Create self-signed CA certificate (10 years)
openssl req -new -x509 -sha256 -key ca-key.pem -out ca-cert.pem -days 3650 \
-subj "/CN=My Internal CA/O=My Company/C=US"
This CA will sign both server and client certificates. In production, keep the CA key offline and secured.
Tip: Use the OpenSSL Command Builder to generate these commands interactively — select "Self-Signed Certificate" and adjust the fields.
# Generate server key
openssl ecparam -genkey -name prime256v1 -out server-key.pem
# Create CSR with SAN
openssl req -new -key server-key.pem -out server.csr \
-subj "/CN=api.example.com/O=My Company" \
-addext "subjectAltName=DNS:api.example.com,DNS:localhost,IP:127.0.0.1"
# Sign with CA
openssl x509 -req -in server.csr -CA ca-cert.pem -CAkey ca-key.pem \
-CAcreateserial -out server-cert.pem -days 365 \
-copy_extensions copy
# Generate client key
openssl ecparam -genkey -name prime256v1 -out client-key.pem
# Create CSR (CN identifies the client)
openssl req -new -key client-key.pem -out client.csr \
-subj "/CN=order-service/O=My Company"
# Sign with the same CA
openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem \
-CAcreateserial -out client-cert.pem -days 365
The client's Common Name (CN=order-service) becomes its identity. Nginx (or your application) can use this to authorize specific clients.
server {
listen 443 ssl;
server_name api.example.com;
# Server certificate (standard TLS)
ssl_certificate /etc/nginx/ssl/server-cert.pem;
ssl_certificate_key /etc/nginx/ssl/server-key.pem;
# Client certificate verification (mTLS)
ssl_client_certificate /etc/nginx/ssl/ca-cert.pem;
ssl_verify_client on;
ssl_verify_depth 2;
# Pass client identity to backend
proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
proxy_set_header X-Client-Verify $ssl_client_verify;
location / {
proxy_pass http://localhost:3000;
}
}
Key directives:
ssl_client_certificate: Path to the CA certificate that signed client certsssl_verify_client on: Require a valid client certificate (use optional to allow but not require)ssl_verify_depth: Maximum chain depth for client certificate verification$ssl_client_s_dn_cn: Extracts the client's Common Name for use in headersTip: Generate this config with the Nginx Config Generator — use reverse proxy mode and enable SSL.
# Successful mTLS request
curl --cert client-cert.pem --key client-key.pem \
--cacert ca-cert.pem \
https://api.example.com/health
# Without client cert (should fail with 400 or connection reset)
curl --cacert ca-cert.pem https://api.example.com/health
# curl: (56) OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required
# With wrong client cert (not signed by the server's trusted CA)
curl --cert wrong-cert.pem --key wrong-key.pem \
--cacert ca-cert.pem \
https://api.example.com/health
# curl: (56) OpenSSL SSL_read: error:0A000418:SSL routines::tlsv13 alert unknown ca
You can inspect both client and server certificates with the SSL Certificate Decoder to verify subjects, issuers, and validity before deploying.
The easiest way to add mTLS to Kubernetes is through a service mesh. Istio injects sidecar proxies (Envoy) that handle mTLS automatically:
# Enable strict mTLS for the entire mesh
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system
spec:
mtls:
mode: STRICT
With STRICT mode, all pod-to-pod traffic must use mTLS. Istio's internal CA (istiod) automatically issues and rotates certificates for every workload.
Without a service mesh, use cert-manager to issue certificates and mount them as Kubernetes secrets:
# Certificate issuer (private CA)
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: internal-ca
spec:
ca:
secretName: ca-key-pair
---
# Certificate for a service
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: payment-service-cert
spec:
secretName: payment-tls
issuerRef:
name: internal-ca
commonName: payment-service
dnsNames:
- payment-service
- payment-service.default.svc.cluster.local
duration: 720h # 30 days
renewBefore: 168h # 7 days
The application then mounts the payment-tls secret and configures its TLS server to require client certificates.
SSL routines::tlsv13 alert unknown ca
The server's ssl_client_certificate doesn't include the CA that signed the client certificate. If you're using intermediate CAs, the server needs the full CA chain:
cat intermediate-ca.pem root-ca.pem > trusted-cas.pem
See SSL Certificate Chains Explained for more on building proper chains.
SSL routines::sslv3 alert certificate expired
Client certificates expire just like server certificates. Automate rotation using cert-manager, HashiCorp Vault, or a renewal script.
# Check client cert expiry
openssl x509 -in client-cert.pem -noout -enddate
In microservices, the client certificate's CN or SAN identifies the calling service. If your authorization logic checks CN=order-service but the cert says CN=order-svc, the request will be rejected at the application level (not TLS level).
If your client certificate was signed by an intermediate CA, the client must send the full chain (client cert + intermediate). Otherwise the server can't build a path to the trusted root:
# Concatenate client cert with intermediate
cat client-cert.pem intermediate-ca.pem > client-fullchain.pem
# Use the full chain in curl
curl --cert client-fullchain.pem --key client-key.pem \
--cacert ca-cert.pem https://api.example.com
| Feature | mTLS | API Keys | JWT |
|---|---|---|---|
| Authentication | Certificate-based | Shared secret | Token-based |
| Transport security | Built into TLS | Requires HTTPS separately | Requires HTTPS separately |
| Revocation | CRL/OCSP | Manual (rotate key) | Token expiry / blocklist |
| Identity | Strong (cryptographic) | Weak (anyone with the key) | Medium (signed claims) |
| Automation | cert-manager, Vault | Manual distribution | OAuth2 / OIDC flows |
| Performance | Slight TLS overhead | None | Token validation overhead |
| Best for | Service-to-service | Simple API access | User authentication |
| Complexity | High (PKI infrastructure) | Low | Medium |
Use mTLS when:
Use API keys when:
Use JWTs when:
mTLS provides the strongest form of service authentication available. Both sides prove their identity cryptographically, and the trust is established at the TLS layer — before any application code runs. The tradeoff is complexity: you need a private CA, certificate distribution, and rotation.
Key takeaways:
For hands-on practice: