|
| 1 | +# Mutual TLS (mTLS) Client Authentication |
| 2 | + |
| 3 | +Authenticate to Auth0 with a TLS client certificate instead of a client secret (RFC 8705). The certificate is presented during the TLS handshake; no credential travels in the request body. |
| 4 | + |
| 5 | +## Prerequisites |
| 6 | + |
| 7 | +- Auth0 **Enterprise** tenant with the **Highly Regulated Identity** add-on |
| 8 | +- A `self_managed_certs` **custom domain** configured on the tenant |
| 9 | +- **Allow mTLS Endpoint Aliases** enabled on the tenant (Dashboard → Settings → Advanced) |
| 10 | +- Client application's authentication method set to **mTLS** in Dashboard → Applications → Settings → Credentials |
| 11 | + |
| 12 | +## Generating a client certificate (development) |
| 13 | + |
| 14 | +```bash |
| 15 | +# Self-signed CA + client cert (development only — use your PKI in production) |
| 16 | +openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 365 -nodes \ |
| 17 | + -subj "/CN=dev-ca" |
| 18 | +openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes \ |
| 19 | + -subj "/CN=my-app-client" |
| 20 | +openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ |
| 21 | + -out client.crt -days 365 |
| 22 | +``` |
| 23 | + |
| 24 | +## Wiring into `ServerClient` |
| 25 | + |
| 26 | +```python |
| 27 | +import ssl |
| 28 | +from auth0_server_python.auth_server.server_client import ServerClient |
| 29 | + |
| 30 | +ssl_context = ssl.create_default_context() # trusts system/public CAs for the server side |
| 31 | +ssl_context.load_cert_chain("client.crt", "client.key") # attaches the client identity |
| 32 | + |
| 33 | +auth0 = ServerClient( |
| 34 | + domain="login.example.com", # self_managed_certs custom domain |
| 35 | + client_id="<AUTH0_CLIENT_ID>", |
| 36 | + use_mtls=True, |
| 37 | + ssl_context=ssl_context, |
| 38 | + secret="<AUTH0_SECRET>", |
| 39 | + authorization_params={ |
| 40 | + "audience": "<API_IDENTIFIER>", |
| 41 | + "scope": "openid profile email offline_access", |
| 42 | + }, |
| 43 | +) |
| 44 | +``` |
| 45 | + |
| 46 | +The SDK passes `ssl_context` as `verify=ssl_context` to every `httpx.AsyncClient` it constructs, including the authlib client used for the authorization-code exchange. You never call `load_cert_chain` inside the SDK — the caller owns the TLS material. |
| 47 | + |
| 48 | +## Mutual exclusion |
| 49 | + |
| 50 | +`use_mtls=True` cannot be combined with: |
| 51 | + |
| 52 | +| Parameter | Reason | |
| 53 | +|-----------|--------| |
| 54 | +| `client_secret` | One client-auth method only — Auth0 rejects requests carrying both. | |
| 55 | +| `client_assertion_signing_key` | Same — one method only. | |
| 56 | +| `dpop_key` (per-call on `signin_with_passkey` / `mfa.verify`) | DPoP binds to its own key (`cnf.jkt`) and suppresses `cnf.x5t#S256`; combining them silently defeats mTLS token binding. | |
| 57 | + |
| 58 | +All three raise `ConfigurationError` immediately (constructor for the first two, at the call site for DPoP). |
| 59 | + |
| 60 | +## Token sender-constraining |
| 61 | + |
| 62 | +When the target API has **Token Sender-Constraining (mTLS)** enabled, issued access tokens carry a `cnf.x5t#S256` claim binding the token to the certificate thumbprint. The SDK warns if it receives a token that lacks this claim: |
| 63 | + |
| 64 | +> `UserWarning: mTLS is enabled but the access token is not certificate-bound (no cnf.x5t#S256). Sender-constraining is not active — configure Token Sender-Constraining (mTLS) on the API resource server.` |
| 65 | +
|
| 66 | +To verify the thumbprint yourself: |
| 67 | + |
| 68 | +```bash |
| 69 | +openssl x509 -in client.crt -outform DER | openssl dgst -sha256 -binary | openssl enc -base64 | tr '+/' '-_' | tr -d '=' |
| 70 | +# Compare the output to the cnf.x5t#S256 claim in the decoded access token. |
| 71 | +``` |
| 72 | + |
| 73 | +## MFA under mTLS |
| 74 | + |
| 75 | +The client certificate is presented on all MFA API calls. Only the token-endpoint call inside `mfa.verify` is routed through the mTLS alias; challenge and enrollment calls stay on the standard host (the standard host does not request a client certificate, so the loaded context is inert on those calls). |
| 76 | + |
| 77 | +When calling `client.mfa.verify` directly (rather than through the SDK's built-in flow), pass the resolved mTLS token endpoint: |
| 78 | + |
| 79 | +```python |
| 80 | +metadata = await auth0._get_oidc_metadata_cached(domain) |
| 81 | +mtls_token_endpoint = auth0._resolve_token_endpoint(metadata) |
| 82 | + |
| 83 | +await auth0.mfa.verify( |
| 84 | + {"mfa_token": encrypted_token, "otp": "123456"}, |
| 85 | + token_endpoint_override=mtls_token_endpoint, |
| 86 | +) |
| 87 | +``` |
0 commit comments