Mutual TLS (mTLS)
AuthzX supports optional mutual TLS client certificate authentication on top of the standard bearer-token (OAuth 2.0 client credentials) auth. When enabled, a client must present BOTH:
- A TLS client certificate whose SHA-256 fingerprint matches a value registered on the OAuth client, AND
- A valid
client_id+client_secretpaired with a valid bearer token exchange.
Missing or mismatched cert — the request is rejected before the secret is ever verified.
Who this is for
mTLS is opt-in and is primarily aimed at:
- Regulated workloads (SOC 2, FedRAMP, HIPAA, PCI) where network-layer identity is a control requirement.
- Enterprise environments that want the blast radius of a leaked
client_secretbounded to the attacker's ability to also exfiltrate the client cert and private key.
If you don't have one of these drivers, bearer-only auth remains fully supported and is the default.
How it works
┌────────────┐ TLS client cert ┌────────┐ X-Client-Cert-Fingerprint ┌──────────────────┐
│ client │ ────────────────────► │ NGINX │ ──────────────────────────► │ identity-service │
└────────────┘ │ (edge) │ │ /oauth/token │
│ bearer secret └────────┘ └──────────────────┘
└──────── (inside TLS) ────────────────────────────────────────────────────┘
- NGINX terminates TLS at the edge. With mTLS enabled, it requires a client cert signed by your configured CA bundle.
- NGINX computes the SHA-256 fingerprint of the presented client cert and forwards it as the
X-Client-Cert-Fingerprintheader to identity-service. - identity-service looks up the OAuth client by
client_id. If the client hasclient_cert_fingerprintsconfigured, the forwarded fingerprint MUST match one of them. Otherwise the request is rejected withinvalid_client. - If the client has no fingerprints configured, identity-service falls through to standard bearer-token checks — no behavior change for legacy clients.
Enabling mTLS at the gateway
Default deployments ship mTLS off. To enable:
1. Provide a CA bundle and server cert
Put these on the NGINX host (or in your secrets manager, mounted at container start):
| File | Purpose |
|---|---|
/etc/nginx/tls/server.crt | Your API server's TLS cert (normal TLS, for the HTTPS listener). |
/etc/nginx/tls/server.key | Server key. |
/etc/nginx/tls/client-ca.pem | CA bundle that signs the client certs you trust. Can be your internal PKI, a public CA scoped to your org, or a self-signed root you control. |
Point to the bundle via the CLIENT_CERT_CA_BUNDLE environment variable in your deployment automation.
2. Activate the mTLS server block
gateway/nginx/mtls.conf.example ships as a template. Your deploy tooling should:
# Only when the operator has opted in
if [ "$ENABLE_MTLS" = "true" ]; then
cp /opt/authzx/gateway/nginx/mtls.conf.example /etc/nginx/conf.d/mtls.conf
fi
The main nginx.conf has:
include /etc/nginx/conf.d/mtls*.conf;
so when the file is not present, the glob matches nothing and the mTLS listener simply does not exist. When present, NGINX brings up a second server block on port 443 with ssl_verify_client on.
3. SHA-256 fingerprint note (important)
NGINX's built-in $ssl_client_fingerprint variable is SHA-1 by default. AuthzX expects SHA-256. The example config uses $ssl_client_fingerprint_sha256, which is available in modern NGINX builds (≥ 1.21 with OpenSSL 3) but not universally. Options:
- Preferred: use a build that exposes
$ssl_client_fingerprint_sha256directly. - Fallback: compute via
njsorluafrom$ssl_client_raw_cert, or via a sidecar that hashes the DER-encoded cert once per TLS session and injects the result as a header.
Confirm which path your build supports before rolling out — a misconfigured gateway will forward a SHA-1 digest that identity-service will never match, and every request will 401 with invalid_client.
Registering a fingerprint on an OAuth client
Compute the fingerprint
From a client cert file:
openssl x509 -in client.crt -noout -fingerprint -sha256 \
| sed 's/^.*=//' \
| tr -d ':' \
| tr '[:upper:]' '[:lower:]'
Output:
ab0123456789ab0123456789ab0123456789ab0123456789ab0123456789abcd
AuthzX accepts uppercase, lowercase, with or without colons — the platform-service API normalizes on write — but the normalized form above is what ends up in the database.
Attach it to the client
Include client_cert_fingerprints when you create the OAuth client:
curl -X POST https://api.authzx.com/platform-srv/v1/oauth/clients \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "prod-service-A",
"scopes": ["authorize:read"],
"client_cert_fingerprints": [
"ab0123456789ab0123456789ab0123456789ab0123456789ab0123456789abcd"
]
}'
Or leave the field empty/omitted to keep bearer-only behavior.
Updating an existing client uses the same field on the PATCH /platform-srv/v1/oauth/clients/:id endpoint.
Rotating client certificates with zero downtime
client_cert_fingerprints is an array specifically so you can do no-downtime rotation:
-
Issue the new client cert. Compute its SHA-256 fingerprint.
-
Add the new fingerprint to
client_cert_fingerprintsalongside the old one:"client_cert_fingerprints": ["<OLD-fingerprint>","<NEW-fingerprint>"]Both certs now work.
-
Roll every client instance from the old cert to the new cert. Traffic succeeds throughout.
-
Remove the old fingerprint once you've confirmed no instances are still presenting it:
"client_cert_fingerprints": ["<NEW-fingerprint>"]
At no point is there a flip-day where requests fail. This is the entire reason the column is plural.
Verifying with curl
Once the gateway and client are configured:
curl --cert client.crt \
--key client.key \
-X POST https://api.authzx.com/identity-srv/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"
Expected outcomes:
| Scenario | Response |
|---|---|
| Valid cert matching a registered fingerprint + valid secret | 200 with access token |
| Valid cert whose fingerprint is NOT registered on this client | 401 {"error":"invalid_client"} |
Missing cert on a client that has client_cert_fingerprints set | 401 {"error":"invalid_client"} |
| No cert and the client has no fingerprints configured (legacy) | 200 with access token |
The API intentionally returns the same generic invalid_client error for every cert-related failure so an attacker cannot probe which clients have mTLS enabled.
Troubleshooting
- Every request 401s with a valid cert. Confirm the gateway is forwarding a SHA-256 fingerprint and not SHA-1 — see the build note above. The request log in audit-service will show
reason=client_cert_mismatch. - Request reaches identity-service without the header at all. Confirm
ssl_verify_client on;is active on the 443 listener and thatproxy_set_header X-Client-Cert-Fingerprint ...;is set in everylocationblock. - Client works against the HTTP listener but fails on the HTTPS/mTLS listener. Verify DNS and the port you're hitting. The mTLS server block is separate; if you accidentally hit the plain-HTTP listener, identity-service will see no header and reject any client that has fingerprints configured.
Related
- OAuth client credentials — the underlying token-exchange flow.
- IP allowlisting on OAuth clients — the network-layer sibling of fingerprint binding; both can be used together for defense in depth.