Mutual TLS (mTLS): Complete Guide to Client Certificate Authentication

Most of the time, TLS is one-way: the server proves its identity to the client by presenting a certificate, and the client just… connects. mTLS flips this around so the client also presents a certificate that the server validates. Both sides authenticate.

I ran into mTLS for the first time when building an internal API that several microservices needed to call. Password auth felt wrong for service-to-service communication, JWT was an option but added complexity, and then someone suggested mTLS. I spent a few days figuring it out and now I use it regularly for service-to-service authentication.

When mTLS Makes Sense

Not every situation calls for mTLS. It adds operational overhead — you need to issue, rotate, and distribute client certificates, and you need a CA infrastructure to manage it. The cases where it genuinely pays off:

  • Service-to-service communication in microservices architectures. Kubernetes with a service mesh (Istio, Linkerd) automates mTLS between pods.
  • API clients where you control both ends — your own internal services, devices you manufacture, or enterprise integrations where the other party can install a certificate.
  • Zero-trust network architectures where network location isn’t trusted and every connection must be authenticated.

I wouldn’t use mTLS for regular web users because managing client certificates for end users is a nightmare of user experience. But for servers talking to servers, it’s elegant.

Building a Simple CA

To issue client certificates, you need a CA. For internal use, you can create your own — similar to creating a self-signed certificate but used as a CA root:

# Create CA key and self-signed certificate
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
  -subj "/C=US/ST=CA/O=My Internal CA/CN=My Internal CA"

This creates a root CA certificate (ca.crt) and private key (ca.key). Keep ca.key extremely secure — it’s the key to your entire trust chain. The CA cert (ca.crt) will be distributed to servers that need to verify client certificates.

Issuing a Client Certificate

# Generate client key
openssl genrsa -out client.key 2048

# Generate CSR
openssl req -new -key client.key -out client.csr \
  -subj "/C=US/O=My Internal/CN=service-payments"

# Sign with CA
openssl x509 -req -days 365 -in client.csr \
  -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out client.crt \
  -sha256

The CN field (service-payments here) is how you’ll identify the client on the server side. You can use it to route requests, apply permissions, or log which service made each request.

Configuring mTLS in Nginx

The server-side configuration in Nginx (assumes you already have SSL set up in Nginx):

server {
    listen 443 ssl;
    server_name api.internal.example.com;

    ssl_certificate     /etc/ssl/server.crt;
    ssl_certificate_key /etc/ssl/server.key;

    # mTLS: require client certificate
    ssl_client_certificate /etc/ssl/ca.crt;
    ssl_verify_client on;
    ssl_verify_depth 2;

    location / {
        # Pass client cert info to the application
        proxy_set_header X-Client-Cert-Subject $ssl_client_s_dn;
        proxy_set_header X-Client-Verify $ssl_client_verify;
        proxy_pass http://backend:8080;
    }
}

ssl_client_certificate points to your CA certificate. ssl_verify_client on tells Nginx to require a valid client certificate — connections without one will be rejected at the TLS layer.

The $ssl_client_s_dn variable contains the client certificate’s Distinguished Name (e.g., CN=service-payments,O=My Internal), which you can pass to your application as a header. Your app can then know which service made the request without any additional authentication.

If you want to allow some endpoints without a client cert and require it on others:

ssl_verify_client optional;

location /api/internal/ {
    if ($ssl_client_verify != "SUCCESS") {
        return 403;
    }
    proxy_pass http://backend:8080;
}

location /api/public/ {
    proxy_pass http://backend:8080;
}

optional tells Nginx to accept client certs if provided, but not require them. Your application logic then decides what to do based on $ssl_client_verify.

Connecting with a Client Certificate

Testing with curl:

curl --cert client.crt --key client.key \
  --cacert ca.crt \
  https://api.internal.example.com/api/internal/test

--cacert is for trusting the server’s CA (if it’s an internal CA). --cert and --key provide the client certificate and key.

In a Python application (requests library):

import requests

response = requests.get(
    "https://api.internal.example.com/api/internal/test",
    cert=("client.crt", "client.key"),
    verify="ca.crt"  # or True for public CA
)

In Go:

cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
    log.Fatal(err)
}

caCert, err := os.ReadFile("ca.crt")
// ... create cert pool, add caCert

tlsConfig := &tls.Config{
    Certificates: []tls.Certificate{cert},
    RootCAs:      certPool,
}

client := &http.Client{
    Transport: &http.Transport{TLSClientConfig: tlsConfig},
}

Certificate Rotation

Client certificate rotation is the part that requires careful design. Unlike server certificates where you’re renewing one cert, you might have hundreds of service instances each with their own certificate.

A few approaches I’ve used:

Short-lived certificates with automation: Issue certificates with a 24-hour or 7-day validity. Each service requests a new certificate from an internal CA API (like HashiCorp Vault’s PKI secrets engine) on startup or via a sidecar. This is the most operationally sound approach but requires infrastructure.

Longer-lived certificates with a revocation process: Issue certificates annually. Keep a CRL (Certificate Revocation List) that Nginx can check:

ssl_crl /etc/ssl/crl.pem;

Update the CRL when a service is decommissioned or a certificate is compromised.

Certificate pinning: Some services don’t need the full PKI. If you know exactly which certificates are valid, you can check the certificate fingerprint directly in your application without needing a full CA chain verification.

Debugging mTLS

When things go wrong, the error messages aren’t always clear. Here’s what I check:

“no required SSL certificate was sent” — the client isn’t sending a certificate at all. Either the client isn’t configured to use one, or it’s using the wrong key/cert pair.

“certificate verify failed” — the server couldn’t validate the client certificate. Usually means the CA cert on the server doesn’t match the CA that issued the client cert.

Verbose curl output:

curl -v --cert client.crt --key client.key --cacert ca.crt https://api.example.com/

The verbose output shows the certificate exchange during the handshake, which usually reveals what’s failing.

OpenSSL s_client:

openssl s_client -connect api.example.com:443 \
  -cert client.crt \
  -key client.key \
  -CAfile ca.crt

This shows the full handshake output and any certificate verification errors.

mTLS in Production: Things I’ve Learned

Keeping track of which services have which certificates, when they expire, and which CA issued them is harder than it sounds. I ended up building a simple spreadsheet, then migrating to proper certificate monitoring tooling. If you’re operating more than a handful of internal services, tracking expiry dates manually will catch you off guard.

Also: test your certificate rotation procedure before you need it. I’ve seen teams that had mTLS working beautifully but had never actually rotated a client certificate in production. When a cert expired, the scramble to replace it was stressful because nobody had done it before.


mTLS isn’t the right tool for every authentication problem, but for service-to-service authentication it’s clean, cryptographically strong, and doesn’t require any shared secrets. Once the infrastructure is in place, adding a new service just means issuing a new certificate.

Scroll to Top