mTLS — certificate-bound access tokens
mTLS (RFC 8705) binds an access token to the X.509 certificate that authenticated the client during the TLS handshake. The OP records a SHA-256 thumbprint of the certificate as cnf.x5t#S256 on the issued token; the resource server verifies that the cert presented on the call to the API has the same thumbprint. A leaked token alone is useless — the attacker would also need the certificate and its private key.
mTLS is attractive in deployments that already operate a PKI: B2B service meshes, open-banking environments, and backend-only APIs where every party has a client certificate issued by an internal CA. Because the binding lives at the TLS layer, the application code does not have to sign anything per request; the trade-off is that the TLS terminator (reverse proxy, load balancer) must be configured to expose the verified certificate to the OP.
RFC 8705 has two halves, and feature.MTLS switches both on. One binds access tokens to the client certificate (cnf.x5t#S256). The other lets a client authenticate with the certificate itself, as tls_client_auth or self_signed_tls_client_auth (Client authentication with a certificate). A client may use either half without the other: a private_key_jwt client can still receive certificate-bound tokens.
Specs referenced on this page
- RFC 8705 — Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 7800 — Confirmation (
cnf) claim - RFC 5280 — X.509 PKI certificates
- FAPI 2.0 Security Profile — Final
Trusting the certificate
When feature.MTLS is enabled, the OP obtains a client certificate from the direct TLS handshake or from the trusted proxy path described below, computes its RFC 8705 thumbprint, and binds the issued token. For token binding alone, chain validation normally belongs to the TLS terminator. If the OP must validate the chain itself, pass a non-nil public pool:
import "crypto/x509"
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
op.WithFeature(feature.MTLS),
op.WithMTLSRootCAs(pool),WithMTLSRootCAs(pool *x509.CertPool) is a public option. A non-nil pool is used for both handshake certificates and certificates selected from WithMTLSProxy; the chain must validate for client authentication. Passing nil is a configuration error. To trust nothing, pass an empty x509.NewCertPool(). Leave the option unset for token binding when the direct TLS stack or trusted proxy has already validated the external client chain. A deployment that authenticates clients with tls_client_auth behind a proxy cannot leave it unset (see below).
Confirmation claim — cnf.x5t#S256
When the OP issues a token to an mTLS-authenticated client, it computes a SHA-256 digest of the DER-encoded certificate (RFC 8705 §3) and writes it into the access token as cnf.x5t#S256. Every subsequent request that uses this access token must arrive over a TLS connection presenting the same certificate; the resource server hashes the cert it observes and compares against cnf.x5t#S256.
The binding happens only when the token request presents a certificate. A request without one gets a plain bearer token, except under profile.FAPI2Baseline, profile.FAPI2MessageSigning, or profile.FAPICIBA, which refuse it with 400 invalid_request. The refresh token is bound the same way, for every client type: the chain remembers the certificate thumbprint, and each refresh must present the same certificate or it fails with invalid_grant. mTLS never upgrades a bearer chain mid-way; the binding is fixed at issuance.
At /userinfo, a token bound to a certificate is rejected with 401 and invalid_token when the certificate is missing or different; the response does not say which. The OP's introspection response reports the binding as cnf and still says token_type: "Bearer".
cnf is shared with DPoP (RFC 7800), but the member name differs: DPoP uses jkt, mTLS uses x5t#S256. A token can carry both members. Where both bindings are present, the consuming endpoint must enforce the conjunction: the DPoP proof must match jkt and the presented certificate must match x5t#S256. The library applies this conjunction at /userinfo; device-code and CIBA grant binding checks likewise require every recorded method.
Why a thumbprint and not the full certificate?
The same reason DPoP records the JWK thumbprint: a fixed-length digest is stable across re-encoding, cheap to compare, and small enough to fit comfortably in a JWT. SHA-256 is pinned by RFC 8705 §3; no negotiation is allowed.
Reverse-proxy deployments
In many production deployments the OP does not terminate TLS itself. An nginx, envoy, AWS ALB, or cloud LB sits in front, decrypts the TLS connection, and forwards the request to the OP as plain HTTP. By that time the client certificate is no longer on the connection — the proxy must forward it as an HTTP header such as X-SSL-Cert.
The OP needs to know which header carries the cert and which IP ranges are allowed to set it. Without the second guard, any internet client could send a forged header and impersonate a properly-authenticated client.
op.WithMTLSProxy(op.MTLSProxy{
HeaderName: "X-SSL-Cert",
TrustedProxies: []string{"10.0.0.0/8"},
})op.MTLSProxy has two fields:
HeaderNamemust not be empty. To disable the header path, omit the option entirely.TrustedProxieslists the CIDRs allowed to set the header. When it is empty, the OP uses the ranges given toop.WithTrustedProxies. If both are empty,op.Newfails, so a misconfiguration cannot silently widen the allow-list.
WithMTLSProxy and WithMTLSRootCAs take effect only together with feature.MTLS. Configuring either without the feature is an op.New error, whatever the profile: an option that would be read by nothing is a misconfiguration, not a no-op.
The header must hold exactly one field with exactly one CERTIFICATE PEM block. Duplicate fields, a certificate chain, or trailing data are rejected as a malformed certificate (invalid_request) so two hops cannot pick different leaves. A request from a trusted proxy that carries no header is treated as presenting no certificate, even if the proxy's own transport certificate is on the connection.
The direct path reads http.Request.TLS.PeerCertificates; the Go HTTP server has to be configured to request client certificates (tls.Config.ClientAuth) for any to arrive. The proxy path is consulted only when the request's RemoteAddr is inside one of the configured trusted CIDRs; a request from elsewhere ignores the header and fails closed. When the request comes from a trusted proxy, the forwarded certificate is authoritative and takes precedence over any certificate on the proxy-to-OP TLS hop. The proxy's transport certificate is not treated as the OAuth client certificate.
(*op.Provider).MTLSProxy() returns the recorded op.MTLSProxy, or the zero value when the option was not configured. TrustedProxies in the returned value is the effective list after the fallback to op.WithTrustedProxies, as CIDR strings in a fresh slice. Embedder-side edge code can use that public projection to keep its header-stripping policy aligned with the OP; it does not require importing an internal verifier.
Client authentication with a certificate
A client registered with one of the two RFC 8705 §2 methods authenticates at every endpoint that authenticates clients: token, PAR, introspection, revocation, CIBA backchannel authentication and device authorization. With feature.MTLS enabled, discovery adds both methods to the supported authentication methods of those endpoints; without the feature they are not advertised.
| Method | Seed type | What the OP checks |
|---|---|---|
tls_client_auth (RFC 8705 §2.1) | op.TLSClientAuthClient | The certificate's chain validates, and the certificate matches the one registered identifier |
self_signed_tls_client_auth (RFC 8705 §2.2) | op.SelfSignedTLSClientAuthClient | The certificate's public key equals a key in the client's JWKS or the document at its JWKSURI; no chain is walked |
op.TLSClientAuthClient takes exactly one of SubjectDN, SANDNS, SANURI, SANIP and SANEmail: the five registration values of RFC 8705 §2.1.2. Zero or several is a registration error. op.SelfSignedTLSClientAuthClient takes JWKS or JWKSURI, and a JWKSURI is fetched through the same SSRF-gated path as any other client jwks_uri (op.WithAllowPrivateNetwork(op.OutboundJWKS), op.WithJWKSHTTPTransport). Dynamic registration accepts and returns the same metadata.
tls_client_auth never accepts a certificate whose chain was not verified. Over a direct TLS connection that means the Go server verified the client certificate against its ClientCAs (r.TLS.VerifiedChains is not empty). Behind op.WithMTLSProxy the OP verifies the forwarded certificate against op.WithMTLSRootCAs, so that option is required: op.New fails when tls_client_auth is combined with WithMTLSProxy and no root pool. A forwarded certificate the OP did not verify cannot authenticate a PKI client.
The certificate that authenticated the client also becomes the binding of the access token it receives (cnf.x5t#S256), so no second certificate or proof is involved. A failed authentication is an invalid_client response and an audit event, the same as for any other method.
Wiring
Minimal mTLS wiring behind a proxy:
import (
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/feature"
)
op.New(
/* required options */
op.WithFeature(feature.MTLS),
op.WithMTLSProxy(op.MTLSProxy{
HeaderName: "X-SSL-Cert",
TrustedProxies: []string{"10.0.0.0/8"},
}),
op.WithMTLSRootCAs(pool), // required for tls_client_auth clients behind a proxy
)When the OP terminates TLS itself (test environments, single-tenant on-prem deployments), the WithMTLSProxy line can be omitted — the OP reads the certificate directly from http.Request.TLS.PeerCertificates. Add WithMTLSRootCAs(pool) populated with the deployment CA when the OP should re-check the chain.
op.WithProfile(profile.FAPI2Baseline) imposes RequiredAnyOf over [DPoP, MTLS]. If neither is explicit, the profile selects DPoP as the default member. Deployments that want mTLS should enable feature.MTLS; that explicit choice satisfies the constraint and suppresses the DPoP default. The same feature also makes tls_client_auth and self_signed_tls_client_auth available, so a FAPI client can authenticate with its certificate or with private_key_jwt. FAPI 2.0 Baseline lists the seed types.
Pitfalls
- TLS terminator must export the cert correctly. Proxies differ in header name and encoding. The OP accepts a single PEM certificate, raw or percent-encoded (nginx
$ssl_client_escaped_cert), and not DER or a structured multi-field value such as Envoy'sx-forwarded-client-cert. Pin the header name inop.MTLSProxyand have the proxy strip any inbound copy of the header. - Certificate renewal changes the binding. A token is bound to the leaf certificate it was issued with, and so is its refresh chain. After the client's certificate rotates, an old access token no longer satisfies the resource server, and a refresh with the new certificate fails with
invalid_grant. The client has to run the authorization again, so size certificate lifetimes against the refresh-token lifetime. - Registering a certificate client without
feature.MTLS. Discovery advertises the two methods only when the feature is on, so enable it for every client seeded asop.TLSClientAuthClientorop.SelfSignedTLSClientAuthClient. RemoteAddrsemantics behind multiple proxies. When the OP sits behind two layers of proxy, only the innermost proxy's IP appears inRemoteAddr— that is the one that must be intrustedCIDRs. Outer proxies are irrelevant to the header allow-list because the OP never sees them directly.
When mTLS shines
- Backend services with existing PKI — every service already has a client certificate from an internal CA. mTLS reuses the infrastructure; no new key-management surface.
- Open banking and B2B service meshes — some regulators and partner programmes mandate mTLS at the network layer. Adopting RFC 8705 layers token binding on top without changing the wire.
- Operations teams already running TLS terminators — the
WithMTLSProxyconfiguration is a one-time wiring exercise that fits naturally next to existing nginx / envoy configs. - Constrained clients that cannot sign per request — the binding lives at the TLS layer; the application code does not produce a fresh signature for every API call.
When mTLS doesn't shine
- Browsers — modern browsers cannot easily present client certificates. SPAs cannot use mTLS in practice; reach for DPoP instead.
- Mobile apps — most platforms allow client certs, but the UX of provisioning and rotating them is poor. DPoP's per-request signing maps better to mobile key stores.
- Deployments without a PKI — standing up an internal CA just to issue client certs is a heavy lift. If you are starting fresh, DPoP gives you sender constraint without the certificate logistics.
- Heterogeneous environments — when some clients are SPAs and others are backend services, you may end up running both mechanisms. Discovery advertises both; clients pick the one they can use.
Read next
- DPoP (RFC 9449) — the alternative sender-constraint mechanism, bound to a client-held key.
- Sender constraint — selection guide — comparison table and when to pick which.
- Use case: FAPI 2.0 Security Profile — full wiring with
private_key_jwtor certificate client authentication and sender constraint. - Design judgments — resolved tensions in the spec stack.