6.3 Mutual TLS: Client Certificates
TLS usually answers “is the server who it claims?” — mutual TLS adds “and is the client?” The strongest authentication on the internet, demonstrated end to end.
Intermediate
What you will learn
The mTLS model: both sides present certificates, both sides verify.
The three
ssl_verify_clientmodes —on,optional,optional_no_ca— and when each is right.The
$ssl_client_*variable family: turning certificate fields into routing, logging, and authorization facts.Failure semantics: what rejected clients see (and why it's at the handshake, not 403).
Real-world patterns: zero-trust service meshes, partner integrations, and the key-management reality.
1. The model
Everything you've done so far is one-way: the client validates the server's certificate against its trust store. Mutual TLS completes the handshake:
1. ClientHello ─────────────────▶
2. ◀───────────── ServerHello + Certificate (server's chain)
3. CertificateRequest ◀───────── "now YOU prove yourself"
4. Certificate ─────────────────▶ (client's chain, signed by a CA
5. ── both verify, keys derived ── the server has been told to trust)From step 3 onward, the server has demanded a client certificate during the handshake — not a username in a header, not a cookie, but a cryptographic identity verified against a CA before a single HTTP byte is exchanged. That's the property that makes mTLS the backbone of zero-trust architectures: there is no "unauthenticated connection" to attack with credential stuffing — there's just a refused handshake.
2. The configuration — and its three modes
The required-mode server, verbatim:
server {
listen 8644 ssl;
server_name app.example.test;
ssl_certificate ../certs/app.example.test.pem;
ssl_certificate_key ../certs/app.example.test.key;
# the mutual part:
ssl_client_certificate ../certs/ca.crt; # CAs we trust for CLIENTS
ssl_verify_client on; # mode: REQUIRED
ssl_verify_depth 2; # chain depth allowed
return 200 "mTLS OK\nverify=$ssl_client_verify\nsubject=$ssl_client_s_dn\nissuer=$ssl_client_i_dn\n";
}The three modes — the entire decision surface:
Mode | No cert | Bad/unknown-CA cert | Good cert |
|---|---|---|---|
| 400 at HTTP (no cert sent) | 400 at HTTP (cert error) | proceed,
|
| proceed,
| 400 at HTTP (cert error) | proceed,
|
| proceed,
| proceed, FAILED:… (unverified!) | proceed,
|
on— the perimeter: partner APIs, internal admin planes, service-to-service where "no certificate" is meaningless.optional— the progressive door: public clients come in certificateless and use other auth; certificate holders skip a step (or get extra trust). No cert and good certs pass with a verdict you can read — a cert that fails verification is still a hard 400.optional_no_ca— nearly always a mistake today (accepts unverified certs: a rogue cert proceeds withFAILED:<reason>); its historical use was "grab any client cert’s fields" — which modern practice does withoptional+ validation elsewhere.
ssl_verify_depth bounds how deep a client chain may go (1 = the CA itself issued the leaf; 2 = one intermediate) — chain-of-trust hygiene, not paranoia.
3. The
$ssl_client_* family — identity as data
The response body demonstrates the workhorses (this is the real capture, presenting the CA-issued
client1):
$ curl -s --cacert certs/ca.crt --resolve app.example.test:8644:127.0.0.1 \
--cert certs/client1.pem --key certs/client1.key https://app.example.test:8644/
mTLS OK
verify=SUCCESS
subject=CN=client1,O=Example Org,C=OO
issuer=CN=Example Org Root CA,O=Example Org,C=OOThe full family and what people build with them:
Variable | Carries | Built into |
|---|---|---|
|
| gateway gates, logs |
| subject DN (RFC 2253) | routing by identity |
| issuer DN | multi-CA partitioning |
| cert serial number | audit trails |
| SHA1 of the cert | cert pinning, allowlists |
| validity window | expiry dashboards (Module 12!) |
| PEM of the cert | passing identity upstream (
|
The routing pattern is pure Lesson 2.4 — identity becomes a
map becomes a policy:
map $ssl_client_s_dn $client_tier {
default guest;
"CN=partner-a,O=Acme,C=US" partner;
"~^CN=internal-" staff; # regex branch
}
# … route, rate-limit (Module 7), and log by $client_tierAnd the upstream handoff — making your apps mTLS-aware without re-terminating:
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Cert $ssl_client_cert; # PEM, URL-encoded by apps4. Failure semantics — read them like a pro
What the three failure classes actually look like (all three captured below):
No certificate,
on
mode:
$ curl -s --cacert certs/ca.crt --resolve app.example.test:8644:127.0.0.1 \
https://app.example.test:8644/ -o /dev/null -w '%{http_code}\n'
400400 No required SSL certificate was sent — NGINX's HTTP-level answer to a client that completed the handshake without presenting the requested cert. TLS 1.2 and TLS 1.3 behave the same way here: the handshake finishes, the request arrives, and the refusal happens at the HTTP layer.
Unknown CA (rogue self-signed cert): the TLS handshake completes — NGINX defers the verdict to the request phase and answers
400 at HTTP. The dedicated codes —
495 "SSL certificate error" for a bad cert,
496 "SSL certificate required" for a missing one — are internal signals: a plain
curl sees only the 400, but an
error_page 495 / 496 block can intercept them and serve your own page. Policy-grade rejection with nothing to brute-force, nothing to parse.
Optional mode's verdict is where nuance lives — a certificateless client proceeds with
NONE, a valid one with
SUCCESS, and a cert that fails verification is rejected outright (
400, not graded). Only
optional_no_ca lets a bad cert through — as
FAILED:<reason>, a
string you route on, not an error:
$ curl -s --cacert … --resolve app.example.test:8645:127.0.0.1 https://app.example.test:8645/
optional mTLS
verify=NONE # no cert offered — normal client5. Production patterns and realities
Zero-trust service mesh: every service holds a SPIFFE-style identity certificate; every inbound listener runs
on+ narrow CA. NGINX (or its sidecar) becomes the policy point;$ssl_client_s_dnis the primary key of authorization. Module 14's Kubernetes story leans on exactly this.Partner integrations: issue per-partner client certs from a dedicated CA;
ssl_client_certificatetrusts that CA only for these servers;$ssl_client_fingerprint+ an allowlist map gives you instant revocation-by-config.The key-management bill: certificates expire (watch
$ssl_client_v_endin logs — alert 30 days out), rotate, and get stolen. mTLS moves auth from "memorable secrets" to "operated secrets" — better failure modes, but a real PKI process (issuance, rotation, revocation) is the price of admission.Revocation reality: CRL checking for client certs exists in OSS —
ssl_crl your-ca-bundle.crl;, rotated with a reload; per-request OCSP for clients does not. Short-lived certs + allowlist maps remain the practical defaults on top.
Try it yourself
Walk the matrix. With
client1’s cert+key against :8644 (success — §3’s capture), with no cert (400), and with a fresh rogue:bashopenssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/rogue.key \ -out /tmp/rogue.pem -days 2 -subj "/CN=rogue-client" curl -s --cacert certs/ca.crt --resolve … --cert /tmp/rogue.pem \ --key /tmp/rogue.key https://app.example.test:8644/ # → rejectedThe optional door. Against :8645 — no cert (
verify=NONE), client1 (SUCCESS), rogue (a hard 400 —optionalstill refuses what it cannot verify; theFAILED:reason string is whatoptional_no_cawould have let through).Identity-driven routing. Build the
$client_tiermap from §3 on the optional server; give one location anif ($client_tier != staff) { return 403; }— Module 11’s sanctioned-if pattern, meeting identity.Expiry awareness. Log
$ssl_client_v_endin an access_log format and check the date — that’s your rotation dashboard’s data source.
Q&A
Q1. A client presents a rogue certificate to an on server. What exactly does it see?
Answer
The handshake completes, then a flat
400 at HTTP — "The SSL certificate error" for a bad cert, "No required SSL certificate was sent" for a missing one. The connection is not reset mid-handshake and there is no 403 to parse: a 400 either way, with the
495/496 internal codes available
behind
error_page if you want a custom body. Nothing to brute-force, nothing to parse.
Q2. Public site + internal admin on one server: which mode, and how do the two populations differ?
Answer
optional — public clients connect certificateless (
NONE) and use session auth; internal clients present certs (
SUCCESS) and the admin locations gate on
$ssl_client_verify/
$ssl_client_s_dn. One listener, two trust levels, the verdict as data.
Q3. Your PKI team asks how NGINX handles client-cert revocation day-to-day. Honest answer?
Answer
Open-source NGINX doesn't check CRL/OCSP per request — practical revocation is short certificate lifetimes plus config-level allowlists (fingerprint/DN maps) that you can flip with a reload. True per-request revocation needs external validation (auth_request to a PVA, Module 11) or Plus.
Q4. How would you forward the client's identity to a backend that wants to make its own authorization decisions?
Answer
proxy_set_header X-Client-Verify $ssl_client_verify; X-Client-DN $ssl_client_s_dn; X-Client-Cert $ssl_client_cert; — the backend receives verdict + subject + the PEM itself and applies its own policy (NGINX authenticated the chain; the app authorizes the identity).
Takeaways
mTLS = CertificateRequest in the handshake: cryptographic client identity verified before HTTP — the zero-trust primitive.
Three modes:
on(gate),optional(two-way door — certificateless pass-through, bad certs still a hard 400), and the near-obsoleteoptional_no_ca(verdict-as-data).$ssl_client_verifycarries the truth in every case.The
$ssl_client_*family turns identities into routing/limits/logs via the tools you already own (maps, headers, log formats).Rejections arrive at HTTP as a flat 400;
optional_no_cafailures are strings you branch on. Revocation = short-lived certs + allowlist maps.Termination + mTLS + header handoff is the standard "NGINX authenticates, applications authorize" architecture.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.