Mutual TLS (mTLS) provides cryptographic authentication for both client and server, making it the gold standard for service-to-service communication in zero-trust architectures. This guide covers everything you need to implement mTLS in production.
How mTLS Works
┌─────────────────────────────────────────────────────────────────┐
│ REGULAR TLS vs mTLS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ REGULAR TLS (One-way) │
│ ───────────────────── │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client │ ─── 1. ClientHello ────► │ Server │ │
│ │ │ ◄── 2. ServerHello ───── │ │ │
│ │ ??? │ ◄── 3. Server Cert ───── │ [CERT] │ │
│ │ │ ─── 4. Key Exchange ───► │ │ │
│ │ │ ◄──► 5. Encrypted ◄────► │ │ │
│ └──────────┘ └──────────┘ │
│ Client is anonymous Server proves identity │
│ │
│ MUTUAL TLS (Two-way) │
│ ──────────────────── │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client │ ─── 1. ClientHello ────► │ Server │ │
│ │ [CERT] │ ◄── 2. ServerHello ───── │ [CERT] │ │
│ │ │ ◄── 3. Server Cert ───── │ │ │
│ │ │ ◄── 4. CertRequest ───── │ (new!) │ │
│ │ │ ─── 5. Client Cert ────► │ │ │
│ │ │ ─── 6. Key Exchange ───► │ │ │
│ │ │ ◄──► 7. Encrypted ◄────► │ │ │
│ └──────────┘ └──────────┘ │
│ Client proves identity Server proves identity │
│ │
└─────────────────────────────────────────────────────────────────┘
When to Use mTLS
| Use Case | mTLS Recommended | Alternative |
|---|---|---|
| Service-to-service (internal) | ✅ Yes | API keys (lower security) |
| Zero-trust architecture | ✅ Yes | Network segmentation (less secure) |
| Service mesh communication | ✅ Yes (usually automatic) | - |
| High-security APIs (finance, health) | ✅ Yes | OAuth + strong auth |
| Public API with many clients | ❌ Usually not | OAuth 2.0 / API keys |
| Browser-based applications | ❌ No (poor UX) | OAuth 2.0 / sessions |
| Mobile apps | ⚠️ Sometimes | OAuth + certificate pinning |
Setting Up a Certificate Authority
For production, use an existing CA (HashiCorp Vault, AWS Private CA, internal PKI). For development/testing:
Create Root CA
# Generate CA private key
openssl genrsa -out ca.key 4096
# Generate self-signed CA certificate
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-out ca.crt \
-subj "/C=US/ST=California/O=MyOrg/CN=MyOrg Root CA"
Generate Server Certificate
# Generate server private key
openssl genrsa -out server.key 4096
# Create server CSR
openssl req -new -key server.key -out server.csr \
-subj "/C=US/ST=California/O=MyOrg/CN=api.example.com"
# Create config for SAN (Subject Alternative Names)
cat > server.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = api.example.com
DNS.2 = *.api.example.com
IP.1 = 10.0.0.1
EOF
# Sign server certificate with CA
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.crt -days 365 -sha256 \
-extfile server.ext
Generate Client Certificate
# Generate client private key
openssl genrsa -out client.key 4096
# Create client CSR
openssl req -new -key client.key -out client.csr \
-subj "/C=US/ST=California/O=MyOrg/CN=service-a"
# Create config for client certificate
cat > client.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature
extendedKeyUsage = clientAuth
EOF
# Sign client certificate with CA
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out client.crt -days 365 -sha256 \
-extfile client.ext
Server Configuration
Nginx
server {
listen 443 ssl;
server_name api.example.com;
# Server certificate
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# Client certificate verification
ssl_client_certificate /etc/nginx/ssl/ca.crt;
ssl_verify_client on; # Require client certs (use 'optional' to allow but not require)
ssl_verify_depth 2;
# Access client cert info in application
location / {
proxy_pass http://backend;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
}
}
Apache
<VirtualHost *:443>
ServerName api.example.com
# Server certificate
SSLEngine on
SSLCertificateFile /etc/apache2/ssl/server.crt
SSLCertificateKeyFile /etc/apache2/ssl/server.key
# Client certificate verification
SSLCACertificateFile /etc/apache2/ssl/ca.crt
SSLVerifyClient require
SSLVerifyDepth 2
# Make client cert info available to application
SSLOptions +StdEnvVars +ExportCertData
</VirtualHost>
Node.js
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt'),
ca: fs.readFileSync('ca.crt'),
requestCert: true,
rejectUnauthorized: true // Reject invalid client certs
};
const server = https.createServer(options, (req, res) => {
const cert = req.socket.getPeerCertificate();
if (req.client.authorized) {
console.log(`Client authenticated: ${cert.subject.CN}`);
res.writeHead(200);
res.end(`Hello ${cert.subject.CN}!\n`);
} else {
res.writeHead(401);
res.end('Client certificate required\n');
}
});
server.listen(443);
Client Configuration
cURL
curl --cert client.crt --key client.key --cacert ca.crt \
https://api.example.com/endpoint
Python (requests)
import requests
response = requests.get(
'https://api.example.com/endpoint',
cert=('client.crt', 'client.key'),
verify='ca.crt'
)
Node.js
const https = require('https');
const fs = require('fs');
const options = {
hostname: 'api.example.com',
port: 443,
path: '/endpoint',
method: 'GET',
key: fs.readFileSync('client.key'),
cert: fs.readFileSync('client.crt'),
ca: fs.readFileSync('ca.crt')
};
const req = https.request(options, (res) => {
// Handle response
});
req.end();
Go
cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
log.Fatal(err)
}
caCert, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
},
},
}
resp, err := client.Get("https://api.example.com/endpoint")
Service Mesh Integration
Istio (Kubernetes)
# Enable strict mTLS for namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: my-namespace
spec:
mtls:
mode: STRICT
---
# Destination rule to use mTLS
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: default
namespace: my-namespace
spec:
host: "*.my-namespace.svc.cluster.local"
trafficPolicy:
tls:
mode: ISTIO_MUTUAL
Linkerd
# Linkerd enables mTLS by default for meshed services
# Annotate namespace to enable automatic injection
apiVersion: v1
kind: Namespace
metadata:
name: my-namespace
annotations:
linkerd.io/inject: enabled
Certificate Rotation Strategy
┌─────────────────────────────────────────────────────────────────┐
│ CERTIFICATE ROTATION TIMELINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Day 0 Day 30 Day 60 Day 90 (Expiry) │
│ │ │ │ │ │
│ ├────────────┼────────────┼────────────┤ │
│ │ Cert A valid (90 days) │ │
│ │ │ │
│ │ ├────────────────────────────────────┤ │
│ │ │ Cert B issued (90 days) │ │
│ │ │ (30-day overlap) │ │
│ │ │
│ Actions: │
│ Day 0: Issue Cert A, deploy to clients │
│ Day 30: Issue Cert B, server trusts both CAs │
│ Day 45: Roll out Cert B to clients │
│ Day 60: Remove Cert A trust from servers │
│ Day 90: Cert A expires (already not in use) │
│ │
└─────────────────────────────────────────────────────────────────┘
Troubleshooting
Test mTLS Connection
# Test with OpenSSL
openssl s_client -connect api.example.com:443 \
-cert client.crt \
-key client.key \
-CAfile ca.crt
# Expected: "Verify return code: 0 (ok)"
Common Errors
| Error | Cause | Solution |
|---|---|---|
| "certificate verify failed" | Client cert not trusted by server | Ensure server has correct CA cert |
| "no certificate returned" | Client not sending cert | Check client config includes cert/key |
| "key values mismatch" | Cert and key don't match | Regenerate cert or use matching pair |
| "certificate has expired" | Certificate past validity | Issue new certificate |
| "unable to get local issuer certificate" | Missing intermediate CA | Include full chain |
Security Best Practices
- Use 4096-bit RSA or P-384 ECDSA keys for CA
- Limit certificate validity (90 days for clients, shorter in high-security)
- Implement proper certificate revocation (CRL or OCSP)
- Use unique certificates per service instance (not shared)
- Rotate CA certificates before expiry (plan 6+ months ahead)
- Monitor certificate expiration with automated alerts
- Store private keys securely (HSMs for CA keys)
Next Steps
- TLS Configuration Hardening - Secure cipher suites and protocols
- Certificate Pinning - Extra protection against CA compromise
- TLS Certificate Complete Guide - Comprehensive certificate management