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.
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), across 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
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 across proxies.
This is the piece implemented now: a per-tenant certificate header and encoding, configured in Central.
Configuration
In the Central admin UI, on a tenant’s Challenges & Security page, 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.
Known Limitations
- Certificate-bound tokens at
edge.edgeterminates no client TLS and reads no certificate header, so it cannot check a caller’s certificate againstcnf.x5t#S256(§3). Instead it accepts a certificate-bound token only from its ownEDGE_SESSIONcookie — a tokenedgeobtained as the mTLS client itself, bound to a certificate onlyedgeholds — and refuses one sent in anAuthorizationheader. A resource behindedgetherefore cannot be called directly by an mTLS client with that client’s own certificate-bound token.