Skip to content
SumGuy's Ramblings
Go back

mTLS Explained: When Regular TLS Isn't Paranoid Enough

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)

  1. Client says hello, lists supported cipher suites
  2. Server sends its certificate (containing its public key) signed by a CA
  3. Client checks: is this cert signed by a CA I trust? Is it for the domain I requested? Is it expired?
  4. Client and server negotiate a session key using the server’s public key
  5. 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

ScenariomTLS Worth It?Notes
Service-to-service internal APIYesCore use case
Zero-trust network segmentationYesReplaces network-level trust
Service mesh (Istio, Linkerd)Auto-handledThey do this for you
Admin API accessYesLimit who can even reach it
Public-facing web appNoUsers don’t have your CA certs
Internal dev toolsMaybeDepends on risk tolerance
Single-developer home labProbably overkillUnless 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:

  1. Deploy step-ca via Docker, grab the root fingerprint
  2. Bootstrap the step CLI on any machine that needs to request certs
  3. Issue server certificates for your services
  4. Issue client certificates for your applications/services
  5. Configure nginx (or your reverse proxy) with ssl_verify_client on
  6. Set up cert renewal via step’s daemon or a systemd timer
  7. 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.


Share this post on:

Previous Post
RAG on a Budget: Building a Knowledge Base with Ollama & ChromaDB
Next Post
Stable Diffusion vs ComfyUI vs Fooocus: AI Image Generation at Home