Regular TLS Has One Blind Spot, and It’s You
Here’s how regular TLS works in one sentence: the server proves it’s legitimate, and the client just… shows up.
When you visit your bank’s website, TLS ensures you’re actually talking to the bank and not some impostor. The bank’s certificate, signed by a trusted certificate authority, proves its identity. Your browser verifies it. Connection established. The bank, meanwhile, has no idea if you’re a human, a bot, or a script running from a datacenter in Minsk. That’s fine for websites — they have other mechanisms for user authentication (passwords, sessions, etc.).
But what about service-to-service communication? When your API gateway calls your auth service, the auth service probably shouldn’t just trust anything that shows up on port 8080. What if an attacker got into your network and spun up something that talks to your auth service? With regular TLS, your auth service can’t tell the difference.
Mutual TLS — mTLS — solves this by requiring both sides to present certificates. The server verifies the client’s cert. The client verifies the server’s cert. Both sides are authenticated before a single byte of application data flows. That’s zero-trust in a concrete, implementable form.
TLS Refresher: What’s Actually Happening
Before we add the “mutual” part, let’s anchor the basics.
The TLS Handshake (Simplified)
- Client says hello, lists supported cipher suites
- Server sends its certificate (containing its public key) signed by a CA
- Client checks: is this cert signed by a CA I trust? Is it for the domain I requested? Is it expired?
- Client and server negotiate a session key using the server’s public key
- Encrypted communication begins
The certificate authority (CA) is the trusted third party. Your browser trusts root CAs (DigiCert, Let’s Encrypt, etc.) that are baked into your OS. When a server presents a cert signed by one of those roots, your browser trusts it.
Adding “Mutual”
mTLS adds a step: after the server sends its cert, the server says “now you show me yours.” The client presents its own certificate. The server checks it against the CAs it trusts. If it’s not signed by a trusted CA, connection refused.
Client Server
| |
|--------- ClientHello ---------->|
|<-------- ServerHello -----------|
|<-------- Server Certificate ----| <- Server proves identity
|<-------- CertificateRequest ----| <- Server asks for client cert
|--------- Client Certificate --->| <- Client proves identity
|--------- ClientKeyExchange ---->|
|============ Encrypted ==========|
For this to work, you need to issue client certificates signed by a CA that your servers trust. For public-facing services, that means a public CA (expensive, complicated). For internal services — and this is where it gets practical — you run your own private CA.
Enter step-ca: Your Private Certificate Authority
Smallstep’s step-ca is an open-source CA designed for exactly this use case. It’s a Go binary that runs a certificate authority with an ACME API, automated renewal, and a sane configuration format. It’s excellent for home labs and internal infrastructure.
Running step-ca with Docker
# docker-compose.yml
version: '3.8'
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
ports:
- "9000:9000"
volumes:
- step-ca-data:/home/step
environment:
- DOCKER_STEPCA_INIT_NAME=MyHomeCA
- DOCKER_STEPCA_INIT_DNS_NAMES=step-ca.internal,localhost
- DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=true
restart: unless-stopped
volumes:
step-ca-data:
On first startup, step-ca initializes with a root CA and intermediate CA. Critically: grab the password it outputs. You’ll need it. It’s printed once and then lives in the volume.
docker compose up -d step-ca
docker compose logs step-ca | grep -A5 "password"
Installing the step CLI
# Linux
wget https://dl.smallstep.com/gh-release/cli/gh-release-header/v0.25.0/step_linux_0.25.0_amd64.tar.gz
tar xf step_linux_0.25.0_amd64.tar.gz
sudo mv step_0.25.0/bin/step /usr/local/bin/
# macOS
brew install step
Bootstrapping a Client
# Point your step CLI at your CA
step ca bootstrap --ca-url https://step-ca.internal:9000 \
--fingerprint YOUR_ROOT_CA_FINGERPRINT
# The fingerprint is in the startup logs:
# docker compose logs step-ca | grep "fingerprint"
This downloads the root CA cert and adds it to step’s trust store. Your machine now trusts certificates issued by your private CA.
Generating Certificates
Server Certificate
# Issue a cert for your internal service
step ca certificate api.internal server.crt server.key \
--ca-url https://step-ca.internal:9000 \
--root ~/.step/certs/root_ca.crt
# This creates server.crt and server.key
# Valid for 24 hours by default (step-ca encourages short-lived certs)
Client Certificate
For mTLS, the client needs its own cert. The “common name” identifies the client — you can use this to distinguish which client is connecting.
# Generate a client cert for "api-gateway" service
step ca certificate api-gateway client.crt client.key \
--ca-url https://step-ca.internal:9000 \
--root ~/.step/certs/root_ca.crt
# For a specific user
step ca certificate user@example.com client.crt client.key \
--ca-url https://step-ca.internal:9000
Automated Renewal
Short-lived certs are great for security (compromise window is small) but annoying to manage manually. step-ca has a renew daemon:
# Run this as a systemd service or in a sidecar container
step ca renew --daemon \
--ca-url https://step-ca.internal:9000 \
--root ~/.step/certs/root_ca.crt \
server.crt server.key
Configuring nginx with mTLS
Here’s where it gets concrete. You have two nginx configurations to care about: the server requiring client certs, and a client making requests with its cert.
Server-Side nginx Configuration
server {
listen 443 ssl;
server_name api.internal;
# Server certificate
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
# CA to verify client certificates against
ssl_client_certificate /etc/nginx/certs/root_ca.crt;
# Require client certificates (change to 'optional' to allow both)
ssl_verify_client on;
# Only TLS 1.2 and 1.3
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
location / {
# Pass client cert info to backend
proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert;
proxy_set_header X-SSL-Client-Subject $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_pass http://backend:8080;
}
}
With ssl_verify_client on, nginx will reject any connection that doesn’t present a valid client certificate signed by your root CA. Unauthorized clients get a TLS handshake failure before they can even send an HTTP request.
Testing mTLS
# Without client cert - should fail
curl https://api.internal/health
# curl: (56) OpenSSL SSL_read: error:14094412:SSL routines:...
# With client cert - should work
curl --cert client.crt --key client.key \
--cacert root_ca.crt \
https://api.internal/health
# {"status": "ok"}
Client-Side with curl / Python
# curl
curl --cert /path/to/client.crt \
--key /path/to/client.key \
--cacert /path/to/root_ca.crt \
https://api.internal/endpoint
# Python requests
import requests
response = requests.get(
'https://api.internal/endpoint',
cert=('/path/to/client.crt', '/path/to/client.key'),
verify='/path/to/root_ca.crt'
)
When mTLS Makes Sense vs. When It’s Overkill
| Scenario | mTLS Worth It? | Notes |
|---|---|---|
| Service-to-service internal API | Yes | Core use case |
| Zero-trust network segmentation | Yes | Replaces network-level trust |
| Service mesh (Istio, Linkerd) | Auto-handled | They do this for you |
| Admin API access | Yes | Limit who can even reach it |
| Public-facing web app | No | Users don’t have your CA certs |
| Internal dev tools | Maybe | Depends on risk tolerance |
| Single-developer home lab | Probably overkill | Unless learning |
Regular TLS is like checking a restaurant’s health certificate — you know the place is legitimate, but they still have to serve anyone who walks through the door. mTLS is also making customers show ID before they can even read the menu. That’s appropriate for a speakeasy. Maybe not for a sandwich shop.
For most home labs, mTLS is a great thing to learn and use selectively. Running step-ca and issuing certs for your internal services is genuinely useful. Requiring client certificates on every service is probably more friction than it’s worth unless you’re specifically practicing zero-trust architectures.
The payoff is real though: once you understand what mTLS is doing, concepts like service meshes, Kubernetes pod-to-pod encryption, and zero-trust networking suddenly make a lot more sense. And that’s worth the afternoon it takes to set up.
Putting It All Together
Your mTLS setup checklist:
- Deploy step-ca via Docker, grab the root fingerprint
- Bootstrap the step CLI on any machine that needs to request certs
- Issue server certificates for your services
- Issue client certificates for your applications/services
- Configure nginx (or your reverse proxy) with
ssl_verify_client on - Set up cert renewal via step’s daemon or a systemd timer
- Test with curl — both the “no cert” rejection and the “with cert” success
Once this is running, you’ve got a private PKI that your entire internal infrastructure can use. That’s not just useful for mTLS — it’s the foundation for internal HTTPS everywhere, SSH certificate authentication, and generally not having to click through self-signed certificate warnings ever again.