Regular TLS (HTTPS) only verifies the server's identity — the client checks the server's certificate, but the server has no idea who the client is. Mutual TLS (mTLS) adds client verification: both sides present certificates and verify each other. It's the gold standard for zero-trust service-to-service communication, used by service meshes (Istio, Linkerd), banking systems, and any environment where API keys aren't secure enough.
How TLS vs mTLS Works
The mTLS Handshake — Step by Step
X.509 Certificates Explained
An X.509 certificate is a digital document that binds a public key to an identity. It's the standard format used by TLS, HTTPS, and mTLS. Here's what's inside:
Certificate Chain of Trust
Certificates form a chain of trust:
- Root CA: Self-signed certificate at the top of the chain. Trusted by all parties (you install it as a "trusted root").
- Intermediate CA (optional): Signed by the Root CA. Used to issue end-entity certificates. Keeps the Root CA offline and safe.
- End-Entity Certificate: The actual server or client certificate. Signed by the Intermediate CA (or directly by the Root CA).
Step 1: Generate Your Own Certificate Authority
In production, you'd use a managed CA (AWS Private CA, Vault PKI, cert-manager). For learning, we'll create our own CA using Python's cryptography library — no OpenSSL CLI needed.
# pip install cryptography flask requests
# generate_certs.py — Complete PKI setup in Python
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timedelta, timezone
import ipaddress
import os
def generate_private_key():
"""Generate a 2048-bit RSA private key."""
return rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
def save_key(key, filename):
"""Save a private key to PEM file."""
with open(filename, "wb") as f:
f.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
print(f" Saved: {filename}")
def save_cert(cert, filename):
"""Save a certificate to PEM file."""
with open(filename, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
print(f" Saved: {filename}")
# ════════════════════════════════════════════════
# STEP 1: Create the Root Certificate Authority
# ════════════════════════════════════════════════
print("\n[1/3] Generating Root CA...")
ca_key = generate_private_key()
ca_name = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MyCompany"),
x509.NameAttribute(NameOID.COMMON_NAME, "MyCompany Root CA"),
])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name) # Self-signed: issuer = subject
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=3650)) # 10 years
.add_extension(
x509.BasicConstraints(ca=True, path_length=None), critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True, key_cert_sign=True, crl_sign=True,
content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False,
encipher_only=False, decipher_only=False,
), critical=True,
)
.sign(ca_key, hashes.SHA256())
)
os.makedirs("certs", exist_ok=True)
save_key(ca_key, "certs/ca-key.pem")
save_cert(ca_cert, "certs/ca-cert.pem")
# ════════════════════════════════════════════════
# STEP 2: Generate Server Certificate
# ════════════════════════════════════════════════
print("\n[2/3] Generating Server Certificate...")
server_key = generate_private_key()
server_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MyCompany"),
x509.NameAttribute(NameOID.COMMON_NAME, "server.local"),
]))
.issuer_name(ca_name) # Signed BY the CA
.public_key(server_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName("localhost"),
x509.DNSName("server.local"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
]), critical=False,
)
.add_extension(
x509.ExtendedKeyUsage([
ExtendedKeyUsageOID.SERVER_AUTH, # This cert is for a SERVER
]), critical=False,
)
.sign(ca_key, hashes.SHA256()) # Signed with CA's private key
)
save_key(server_key, "certs/server-key.pem")
save_cert(server_cert, "certs/server-cert.pem")
# ════════════════════════════════════════════════
# STEP 3: Generate Client Certificate
# ════════════════════════════════════════════════
print("\n[3/3] Generating Client Certificate...")
client_key = generate_private_key()
client_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MyCompany"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Engineering"),
x509.NameAttribute(NameOID.COMMON_NAME, "service-a"),
]))
.issuer_name(ca_name) # Also signed by the same CA
.public_key(client_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.add_extension(
x509.ExtendedKeyUsage([
ExtendedKeyUsageOID.CLIENT_AUTH, # This cert is for a CLIENT
]), critical=False,
)
.sign(ca_key, hashes.SHA256())
)
save_key(client_key, "certs/client-key.pem")
save_cert(client_cert, "certs/client-cert.pem")
print("\n Done! Generated files:")
print(" certs/ca-cert.pem (Root CA certificate — share with all services)")
print(" certs/ca-key.pem (Root CA private key — keep SECRET)")
print(" certs/server-cert.pem (Server certificate)")
print(" certs/server-key.pem (Server private key)")
print(" certs/client-cert.pem (Client certificate)")
print(" certs/client-key.pem (Client private key)")
# Run it:
python generate_certs.py
# Output:
# [1/3] Generating Root CA...
# Saved: certs/ca-key.pem
# Saved: certs/ca-cert.pem
# [2/3] Generating Server Certificate...
# Saved: certs/server-key.pem
# Saved: certs/server-cert.pem
# [3/3] Generating Client Certificate...
# Saved: certs/client-key.pem
# Saved: certs/client-cert.pem
# Verify the certs are valid:
python -c "
from cryptography import x509
cert = x509.load_pem_x509_certificate(open('certs/server-cert.pem','rb').read())
print(f'Subject: {cert.subject}')
print(f'Issuer: {cert.issuer}')
print(f'Valid: {cert.not_valid_before_utc} to {cert.not_valid_after_utc}')
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
print(f'SANs: {san.value.get_all_for(x509.DNSName)}')
"
Step 2: Build the mTLS Server (Flask)
Now let's build a Flask server that requires client certificates:
# mtls_server.py — Flask server with mutual TLS
import ssl
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/api/data")
def get_data():
# Extract client certificate info from the TLS connection
client_cert = request.environ.get("peercert")
if client_cert:
# Get the client's identity from their certificate
subject = dict(x[0] for x in client_cert.get("subject", ()))
client_cn = subject.get("commonName", "unknown")
client_org = subject.get("organizationName", "unknown")
return jsonify({
"message": "mTLS authentication successful!",
"client_identity": {
"common_name": client_cn,
"organization": client_org,
},
"data": {
"secret_value": 42,
"items": ["alpha", "bravo", "charlie"],
},
})
else:
return jsonify({"error": "No client certificate provided"}), 403
@app.route("/api/health")
def health():
return jsonify({"status": "ok", "tls": "mutual"})
if __name__ == "__main__":
# Create SSL context with CLIENT CERTIFICATE VERIFICATION
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
# Load server's certificate and private key
context.load_cert_chain(
certfile="certs/server-cert.pem",
keyfile="certs/server-key.pem",
)
# Load the CA certificate to verify client certificates
context.load_verify_locations(cafile="certs/ca-cert.pem")
# REQUIRE client certificates (this is what makes it "mutual")
context.verify_mode = ssl.CERT_REQUIRED
# Minimum TLS version (reject TLS 1.1 and below)
context.minimum_version = ssl.TLSVersion.TLSv1_2
print("mTLS server running on https://localhost:8443")
print("Client certificate REQUIRED for all connections")
app.run(
host="0.0.0.0",
port=8443,
ssl_context=context,
debug=False,
)
Step 3: Build the mTLS Client
# mtls_client.py — Python client with mutual TLS
import requests
import json
# ── WILL FAIL: No client certificate ──────────
try:
response = requests.get(
"https://localhost:8443/api/data",
verify="certs/ca-cert.pem", # Trust the server's CA
# No client cert! Server will reject this.
)
except requests.exceptions.SSLError as e:
print(f"REJECTED (no client cert): {e}")
# Output: SSLError: certificate required
# ── WILL SUCCEED: With client certificate ──────
response = requests.get(
"https://localhost:8443/api/data",
cert=("certs/client-cert.pem", "certs/client-key.pem"), # Client cert + key
verify="certs/ca-cert.pem", # Trust the server's CA
)
print(f"Status: {response.status_code}")
print(f"Response: {json.dumps(response.json(), indent=2)}")
# Output:
# Status: 200
# Response: {
# "message": "mTLS authentication successful!",
# "client_identity": {
# "common_name": "service-a",
# "organization": "MyCompany"
# },
# "data": {
# "secret_value": 42,
# "items": ["alpha", "bravo", "charlie"]
# }
# }
# ── WILL FAIL: Wrong CA (untrusted cert) ──────
try:
response = requests.get(
"https://localhost:8443/api/data",
cert=("certs/client-cert.pem", "certs/client-key.pem"),
verify=False, # Skip server verification (DON'T do this in production!)
)
except Exception as e:
print(f"Error: {e}")
Step 4: Production-Ready mTLS Server
For production, use gunicorn with SSL instead of Flask's development server:
# Install gunicorn
pip install gunicorn
# Run with mTLS:
gunicorn mtls_server:app \
--bind 0.0.0.0:8443 \
--certfile certs/server-cert.pem \
--keyfile certs/server-key.pem \
--ca-certs certs/ca-cert.pem \
--cert-reqs 2 \
--ssl-version TLSv1_2 \
--workers 4 \
--timeout 30
# --cert-reqs 2 = ssl.CERT_REQUIRED (mTLS enforced)
# --cert-reqs 1 = ssl.CERT_OPTIONAL (mTLS optional)
# --cert-reqs 0 = ssl.CERT_NONE (regular TLS only)
Certificate Rotation
Certificates expire. You need an automated rotation strategy:
# rotate_certs.py — Automated certificate renewal
from datetime import datetime, timezone
from cryptography import x509
def check_expiry(cert_path, warn_days=30):
"""Check if a certificate is near expiry."""
with open(cert_path, "rb") as f:
cert = x509.load_pem_x509_certificate(f.read())
days_left = (cert.not_valid_after_utc - datetime.now(timezone.utc)).days
if days_left <= 0:
print(f"EXPIRED: {cert_path} expired {abs(days_left)} days ago!")
return "expired"
elif days_left <= warn_days:
print(f"WARNING: {cert_path} expires in {days_left} days!")
return "warning"
else:
print(f"OK: {cert_path} valid for {days_left} more days")
return "ok"
# Check all certs
for cert_file in ["certs/ca-cert.pem", "certs/server-cert.pem", "certs/client-cert.pem"]:
check_expiry(cert_file)
# In production:
# 1. Run this as a daily cron job / K8s CronJob
# 2. When "warning": auto-generate new cert, signed by same CA
# 3. Rolling restart services with new certs
# 4. Tools: cert-manager (K8s), HashiCorp Vault PKI, AWS Private CA
mTLS in Docker
# Dockerfile for the mTLS server
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY mtls_server.py .
COPY certs/ certs/
EXPOSE 8443
CMD ["gunicorn", "mtls_server:app", \
"--bind", "0.0.0.0:8443", \
"--certfile", "certs/server-cert.pem", \
"--keyfile", "certs/server-key.pem", \
"--ca-certs", "certs/ca-cert.pem", \
"--cert-reqs", "2"]
# docker-compose.yml
# version: '3.8'
# services:
# server:
# build: .
# ports:
# - "8443:8443"
# volumes:
# - ./certs:/app/certs:ro # Mount certs read-only
#
# client:
# build: .
# command: python mtls_client.py
# volumes:
# - ./certs:/app/certs:ro
# depends_on:
# - server
mTLS with Kubernetes (cert-manager)
# In Kubernetes, use cert-manager to automate certificate lifecycle
# 1. Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.0/cert-manager.yaml
# 2. Create a self-signed Issuer (for internal mTLS)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca
spec:
selfSigned: {}
---
# 3. Create a CA Certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-ca-cert
namespace: cert-manager
spec:
isCA: true
commonName: internal-ca
secretName: internal-ca-secret
issuerRef:
name: internal-ca
kind: ClusterIssuer
---
# 4. Create an Issuer that uses the CA
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: internal-issuer
spec:
ca:
secretName: internal-ca-secret
---
# 5. Request a server certificate (auto-renewed!)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: server-tls
spec:
secretName: server-tls-secret
duration: 720h # 30 days
renewBefore: 168h # Renew 7 days before expiry
commonName: my-service.default.svc.cluster.local
dnsNames:
- my-service
- my-service.default
- my-service.default.svc.cluster.local
usages:
- server auth
- client auth # Enable for mTLS
issuerRef:
name: internal-issuer
# The certificate is auto-mounted as a Kubernetes Secret
# and auto-renewed before expiry. No manual rotation needed!
Security Best Practices
When to Use mTLS
- Microservice-to-microservice: Internal APIs within your cluster. Service meshes (Istio, Linkerd) automate this completely.
- Zero-trust networks: Don't trust the network — verify every connection. mTLS ensures only authorized services communicate.
- Financial/healthcare systems: Regulatory requirements (PCI-DSS, HIPAA) often mandate mutual authentication.
- IoT device authentication: Each device gets a unique certificate — more secure than shared API keys.
- Cross-organization APIs: When two companies need to securely exchange data, each side presents certificates signed by agreed-upon CAs.
mTLS is the strongest form of service authentication available. It eliminates shared secrets (API keys), prevents man-in-the-middle attacks, and provides cryptographic proof of identity for both sides of every connection. With tools like cert-manager and the Python cryptography library, setting up mTLS is no longer reserved for security experts — any developer can build it.