Specification: RFC 8705
Mutual TLS lets a client authenticate to the authorization server with an X.509 certificate instead of a shared secret, and lets the authorization server bind an access token to that certificate so a stolen token is useless without the matching private key. The two mechanisms are independent — a deployment can use either without the other.
Status
Implemented. Checked claims: 17 of 17.
Client Registration Metadata
-
tls_client_authsubject types —subject_dn,san_dns,san_uri,san_ip,san_email(§2.1.2) — stored per client as a single discriminated subject type/value pair, since a certificate matching any one of them would authenticate the client anyway -
tls_client_certificate_bound_access_tokensclient intention flag (§3.4) -
self_signed_tls_client_auth(§2.2) — the certificate authenticates the client when its public key matches one in the client’s registeredjwks, the same column RFC 7523private_key_jwtreads. Keys are compared by encoded key material, so a registered key with different optional members (kid,use) still matches. Registration refuses the method without ajwks, and the registered keys authenticate a certificate only, never a client assertion - Either mTLS method is refused at registration for a tenant whose proxy does not terminate mutual TLS (see TLS Termination) — such a client could never authenticate
Client Authentication
-
tls_client_authandself_signed_tls_client_authat the token endpoint (§2), in all three grants — a client registered for either method authenticates by presenting a matching certificate, and by nothing else: a secret sent alongside is not consulted, and a missing or non-matching certificate isinvalid_client - Both methods at the introspection, revocation, and pushed authorization request endpoints — the same rule, so a client that holds no secret can obtain tokens and then read, revoke and push with them. Introspection also refuses a caller presenting nothing but a
client_id: RFC 7662 requires the caller to be authenticated, and a public client’s id is not a secret
Certificate-Bound Access Tokens
-
cnfclaim with thex5t#S256member on issued access tokens (§3.1) — the base64url SHA-256 of the DER certificate, set for a client that authenticated with one or registered the §3.4 flag - Certificate-bound refresh tokens (§4, §7.1) — a bound grant keeps the binding it was issued with; refreshing it requires the same certificate, and the token is renewed in place rather than rotated, exactly as a DPoP-bound one is
- Both confirmations at once — a certificate-bound token that also carries an RFC 9449
jktkeeps both, rather than the weaker of the two constraints the client proved -
x5t#S256in token introspection responses (§3.2) —/introspectreturns the token’s wholecnf, so a resource server that validates tokens by introspection sees the certificate thumbprint alongside any DPoPjkt - Enforcing the binding at
/userinfo(§3) — a certificate-bound token is honoured only when the request carries the certificate it is bound to; a missing, unreadable, or different certificate isinvalid_token. The check follows the binding on the token, not the client’s current registration, so a client that has since dropped the §3.4 flag cannot use an old bound token without its certificate -
tls_client_certificate_bound_access_tokensauthorization server metadata (§3.3), andtls_client_auth/self_signed_tls_client_authintoken_endpoint_auth_methods_supported— served when the operator sets them in the stored metadata document, which passes them through unchanged. They are not derived automatically: the document is served once for the whole deployment, but whether a certificate can be honoured depends on the proxy each tenant configured -
mtls_endpoint_aliases(§5) — for a tenant with a mutual-TLS listener configured, the discovery document republishes the token, introspection, revocation, pushed-authorization-request, and userinfo endpoint paths under the listener’s own external URL, so a client can discover the address that actually terminates its certificate. Absent that config, the field is omitted entirely — no promise for a listener that doesn’t exist
Mutual TLS listener
- A separate
authlistener (port8083by default) that demands a client certificate in the handshake; a connection without one is refused (MutualTlsListenerSpec) - The listener serves only the endpoints a certificate matters for:
/token,/introspect,/revoke,/parand/userinfo - Enabled by the
mutual-tlsblock of theauthconfiguration, withcertificate,private-key,trusted-certificatesandexternal-url.trusted-certificatesis required, and there is no fallback to the JDK trust store - A DPoP proof sent to the listener is held to the listener’s own address, and a proof made for the main address is refused there (
MutualTlsListenerSpec)
TLS Termination (§6.5)
The RFC explicitly puts this out of scope: “How the client certificate metadata is securely communicated between the intermediary and the application server… is out of scope of this specification.” Versola’s auth service always sits behind a reverse proxy that terminates TLS, so before anything above this line can work, the proxy first has to hand auth the certificate it validated — and there’s no standard header or encoding for that between proxies.
This is the second way to receive a certificate, next to the listener above: a per-tenant certificate header and encoding, configured in Central.
Configuration
In the Central admin UI, on a tenant’s Security page, in the Tokens & keys card, under Mutual TLS:
- Check “This tenant’s reverse proxy terminates mutual TLS”.
- Pick a preset matching your reverse proxy, or enter a custom header and encoding.
| Preset | Header | Encoding |
|---|---|---|
| nginx / ingress-nginx | ssl-client-cert | URL-encoded PEM |
| Traefik | X-Forwarded-Tls-Client-Cert | Base64-encoded DER |
| Custom | any header name | URL-encoded PEM or Base64-encoded DER |
Leaving it unset means the tenant’s proxy does not terminate mTLS — the state every tenant is in before this was configured.
The header is read only for a client the certificate can affect: one that authenticates by certificate, and at /token also one whose tokens are bound to one. For any other client it is ignored, so a tenant whose proxy forwards a certificate on every connection neither constrains tokens nobody asked to constrain nor fails requests that authenticate perfectly well by secret. A header that is present but unreadable is refused as invalid_client rather than treated as an absent certificate — the reason describes the deployment’s proxy, so it is logged instead of returned.
Notes
-
Certificate-bound tokens at
edge. The plainedgeport terminates no client TLS and reads no certificate header, so it cannot check a caller’s certificate againstcnf.x5t#S256(§3). There it accepts a certificate-bound token only from its ownEDGE_SESSIONcookie (a tokenedgeobtained as the mTLS client itself) and refuses one sent in anAuthorizationheader (certificate_bound_token_outside_session). A caller that holds its own certificate-bound token calls the API onedge’s optional mutual-TLS listener instead:edgereads the certificate from the handshake and accepts the token only if its SHA-256 thumbprint equalscnf.x5t#S256. -
Renewed certificates and tokens. A token carries the thumbprint of the certificate it was issued for. After the client renews its certificate (for example through EST), a token bound to the old certificate does not validate with the new one: the client keeps presenting the old certificate with the old tokens until they expire, or requests new tokens with the new certificate.
Where to look
Sender-constrained tokens explains certificate binding, and Edge client certificates covers the certificates edge presents.