Skip to main content

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:

  1. A TLS client certificate whose SHA-256 fingerprint matches a value registered on the OAuth client, AND
  2. A valid client_id + client_secret paired 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_secret bounded 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) ────────────────────────────────────────────────────┘
  1. NGINX terminates TLS at the edge. With mTLS enabled, it requires a client cert signed by your configured CA bundle.
  2. NGINX computes the SHA-256 fingerprint of the presented client cert and forwards it as the X-Client-Cert-Fingerprint header to identity-service.
  3. identity-service looks up the OAuth client by client_id. If the client has client_cert_fingerprints configured, the forwarded fingerprint MUST match one of them. Otherwise the request is rejected with invalid_client.
  4. 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):

FilePurpose
/etc/nginx/tls/server.crtYour API server's TLS cert (normal TLS, for the HTTPS listener).
/etc/nginx/tls/server.keyServer key.
/etc/nginx/tls/client-ca.pemCA 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_sha256 directly.
  • Fallback: compute via njs or lua from $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:

  1. Issue the new client cert. Compute its SHA-256 fingerprint.

  2. Add the new fingerprint to client_cert_fingerprints alongside the old one:

    "client_cert_fingerprints": [
    "<OLD-fingerprint>",
    "<NEW-fingerprint>"
    ]

    Both certs now work.

  3. Roll every client instance from the old cert to the new cert. Traffic succeeds throughout.

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

ScenarioResponse
Valid cert matching a registered fingerprint + valid secret200 with access token
Valid cert whose fingerprint is NOT registered on this client401 {"error":"invalid_client"}
Missing cert on a client that has client_cert_fingerprints set401 {"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 that proxy_set_header X-Client-Cert-Fingerprint ...; is set in every location block.
  • 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.