In ordinary TLS the server proves who it is and the client stays anonymous. That is the right default for a public website: your browser needs to trust the bank, the bank does not need a certificate from every visitor. Mutual TLS (mTLS) turns on the second half of that exchange, so the client presents a certificate too and the server verifies it. It is a strong control for service-to-service traffic, and it multiplies the number of certificates you have to keep alive.

What mTLS Actually Verifies

In a normal handshake the server sends its certificate, the client checks it against a trusted root, and traffic is encrypted. The client's identity never enters the TLS layer, which is why public sites bolt on passwords, cookies, or bearer tokens once the connection is up.

mTLS adds the reverse direction: the server asks the client for a certificate and verifies it against a CA the server trusts. Possession of the private key is the credential, and the key never leaves the holder, so there is no shared secret to phish and no bearer token to steal and replay.

One detail trips people up. Under TLS 1.3 the two sides do not authenticate at the same moment. The server authenticates during the handshake, but the client's Certificate and CertificateVerify travel in its second flight, and the client can already have application data on the wire before the server has finished checking them. Client authentication does not gate the client's first send. That is why an mTLS server like nginx completes the TLS handshake and only then refuses an unauthenticated request, at the HTTP layer, which you can see in the error output further down.

Minting a Client CA and Certificate

Server certificates usually come from a public CA. Client certificates almost never do. You run your own CA and issue from it, because the clients are your own services. Three commands stand up a CA and sign one client certificate:

# 1. create the client CA (keep ca.key offline and guarded)
openssl req -x509 -newkey rsa:2048 -nodes \
    -keyout ca.key -out ca.crt -days 3650 \
    -subj "/CN=Internal Client CA"

# 2. generate a client key and a CSR for one service
openssl req -newkey rsa:2048 -nodes \
    -keyout client.key -out client.csr \
    -subj "/CN=service-a"

# 3. sign the CSR with the CA
openssl x509 -req -in client.csr \
    -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out client.crt -days 365

ca.crt is the bundle the server will trust. client.crt and client.key are what the calling service presents. The CA private key signs every client identity, so it is the single most sensitive file in the system.

Turning on Verification in nginx

Three directives turn a normal TLS vhost into an mTLS one:

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

    ssl_certificate     /etc/nginx/tls/server.crt;
    ssl_certificate_key /etc/nginx/tls/server.key;

    # the CA bundle used to verify client certificates
    ssl_client_certificate /etc/nginx/tls/ca.crt;

    # require a valid client certificate for every request
    ssl_verify_client on;

    # how many intermediates to allow between client cert and CA
    ssl_verify_depth 2;
}

ssl_client_certificate points at the CA bundle, the same ca.crt you issued from. ssl_verify_depth sets how far nginx will walk the chain: 1 is a client signed directly by the CA, 2 leaves room for one intermediate.

Picking an ssl_verify_client Value

on and optional look similar and behave very differently. on refuses any request without a valid certificate at the edge. optional lets the request through and hands your application the verification result to act on, so an app that forgets to check $ssl_client_verify has no client authentication at all while appearing to.

Value Cert requested Behavior
on yes, required Request refused with a 400 unless the cert verifies against the CA. Enforced by nginx at the edge.
optional yes, not required Connection allowed with or without a cert. If one is sent it must verify, otherwise 400. Result exposed in $ssl_client_verify for your app to check.
optional_no_ca yes, not required Cert requested but nginx does not verify it against a CA. Verification is delegated to your application or an upstream service.
off no Default. Plain server-authenticated TLS, no client certificate involved.

The trap is shipping optional because it was easier to get working, then never wiring up the $ssl_client_verify check. The endpoint accepts anonymous callers and looks locked down.

Calling an mTLS Endpoint

From the client you present the certificate and key on every request. curl takes them directly:

curl --cert client.crt --key client.key \
    https://api.internal.example/

To watch the exchange itself, s_client shows the certificates and the negotiated protocol:

openssl s_client -connect api.internal.example:443 \
    -servername api.internal.example \
    -cert client.crt -key client.key

What Failure Looks Like

The reason to test mTLS before you ship it is that the failure modes do not announce themselves as certificate problems. With ssl_verify_client on, a client that presents no certificate gets a flat 400:

$ curl https://api.internal.example/
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>

A certificate signed by a CA the server does not trust, including an expired one issued by a rotated CA, comes back with a different 400:

<head><title>400 The SSL certificate error</title></head>

Both responses arrive after the TLS handshake has already completed, the TLS 1.3 behavior from earlier: nginx finishes the handshake, then rejects the request at the HTTP layer. To a client library this usually surfaces as a generic HTTP 400 or a dropped connection. The error text rarely names the certificate, which is what makes an expired client cert slow to diagnose.

The Certificate Count Goes Up

In public TLS you count server certificates, a set you can enumerate. mTLS makes every client a certificate holder as well. A mesh with two hundred workloads carries at least two hundred client certificates and keys, each with its own expiry, and usually more because platforms issue per instance. When one expires the call fails closed: the service is refused and the outage reads like a connectivity fault.

The trust chain adds a failure with a much larger blast radius. If the internal CA named in ssl_client_certificate expires, every mTLS connection that verifies against it breaks at once. That is why service meshes lean on short-lived, automatically rotated certificates: renewing hundreds of client certs by hand does not scale. It is the same corner the public web is being pushed into by shorter certificate lifetimes, reached from the other side.

Monitoring the Server Side

Monitoring scope splits along a clean line. The certificate a server presents is reachable: you can connect to the endpoint and read its expiry and chain, the way mrdns.com/ssl-check inspects a public one. Client certificates and the internal CA are never presented to an outside monitor. They live in your issuing pipeline, and that is where you track their expiry.

Generator Labs certificate monitoring covers the reachable half. It connects to any endpoint it can reach, public or internal, and alerts on expiry and chain health with lead time. It watches server endpoints, so the client certificates and the CA behind them stay on your issuing pipeline to track. For the nginx side, the client-certificate verification guide at goodtls.com walks through the directives above in full. Start monitoring your endpoints.

Back to Blog