Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~40 min read

Intermediate

What you will learn

  1. The mTLS model: both sides present certificates, both sides verify.

  2. The three ssl_verify_client modes — on, optional, optional_no_ca — and when each is right.

  3. The $ssl_client_* variable family: turning certificate fields into routing, logging, and authorization facts.

  4. Failure semantics: what rejected clients see (and why it's at the handshake, not 403).

  5. 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:

text
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:

nginx
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

on

400 at HTTP (no cert sent)

400 at HTTP (cert error)

proceed, $ssl_client_verify=SUCCESS

optional

proceed, NONE

400 at HTTP (cert error)

proceed, SUCCESS

optional_no_ca

proceed, NONE

proceed, FAILED:… (unverified!)

proceed, SUCCESS

  • 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 with FAILED:<reason>); its historical use was "grab any client cert’s fields" — which modern practice does with optional + 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):

bash
$ 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=OO

The full family and what people build with them:

Variable

Carries

Built into

$ssl_client_verify

NONE / SUCCESS / FAILED:<why>

gateway gates, logs

$ssl_client_s_dn

subject DN (RFC 2253)

routing by identity

$ssl_client_i_dn

issuer DN

multi-CA partitioning

$ssl_client_serial

cert serial number

audit trails

$ssl_client_fingerprint

SHA1 of the cert

cert pinning, allowlists

$ssl_client_v_start/_end

validity window

expiry dashboards (Module 12!)

$ssl_client_cert

PEM of the cert

passing identity upstream ( proxy_set_header X-Client-Cert)

The routing pattern is pure Lesson 2.4 — identity becomes a map becomes a policy:

nginx
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_tier

And the upstream handoff — making your apps mTLS-aware without re-terminating:

nginx
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 apps

4. Failure semantics — read them like a pro

What the three failure classes actually look like (all three captured below):

No certificate, on mode:

bash
$ 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'
400

400 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:

bash
$ 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 client

5. 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_dn is 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_certificate trusts 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_end in 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

  1. Walk the matrix. With client1’s cert+key against :8644 (success — §3’s capture), with no cert (400), and with a fresh rogue:

    bash
    openssl 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/   # → rejected
    
  2. The optional door. Against :8645 — no cert ( verify=NONE), client1 ( SUCCESS), rogue (a hard 400 — optional still refuses what it cannot verify; the FAILED: reason string is what optional_no_ca would have let through).

  3. Identity-driven routing. Build the $client_tier map from §3 on the optional server; give one location an if ($client_tier != staff) { return 403; } — Module 11’s sanctioned-if pattern, meeting identity.

  4. Expiry awareness. Log $ssl_client_v_end in 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-obsolete optional_no_ca (verdict-as-data). $ssl_client_verify carries 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_ca failures are strings you branch on. Revocation = short-lived certs + allowlist maps.

  • Termination + mTLS + header handoff is the standard "NGINX authenticates, applications authorize" architecture.

Was this page helpful?

Mark this learning resource complete to track your learning progress.