Sender-constrained tokens
Bind access tokens to a key your client holds — DPoP (RFC 9449) proofs and mutual TLS (RFC 8705) certificates, the cnf claim, the DPoP nonce retry, refresh-token binding, and what a resource server must check.
A bearer access token is usable by whoever holds it. A sender-constrained token is bound to a key your client proves possession of on every request, so a stolen token is useless without that key.
SenseCrypt implements both mechanisms OAuth 2.0 defines for this, and expresses either one the same way — a cnf (confirmation) claim on the access token naming a SHA-256 thumbprint:
| Mechanism | Registration switch | cnf member | token_type |
|---|---|---|---|
| DPoP (RFC 9449) — a per-request proof JWT signed with the client's own key | dpop_bound_access_tokens | jkt — the RFC 7638 thumbprint of the proof key | DPoP |
| Mutual TLS (RFC 8705) — the client's TLS certificate | tls_client_certificate_bound_access_tokens | x5t#S256 — base64url SHA-256 of the leaf certificate's DER | Bearer |
An application on the FAPI 2.0/CIBA profile must have at least one of the two switches on (fapi.requires_sender_constrained_tokens). Every other application may use DPoP whether or not it registered the switch.
A token carries one cnf. If an application registers both switches, it is certificate-bound: a DPoP proof on the same request is still verified, but the token binds to the certificate.
DPoP
The proof
On each request, your client sends a compact JWS in a DPoP header. More than one DPoP header field (or a comma-joined one) is refused.
// header
{ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" } }
// payload
{
"jti": "e1f3…",
"htm": "POST",
"htu": "https://acme.example.com/v1/idp/oidc/token",
"iat": 1893455400,
"nonce": "…",
"ath": "…"
}typmust bedpop+jwt, andalgmust beES256orPS256(the advertiseddpop_signing_alg_values_supported).noneand the HMAC algorithms are refused.jwkis the public key, in the JOSE header. Ajwkcarrying private members (d,p,q, …) is refused.jti,htm,htuandiatare required.htmis compared case-insensitively against the request method;htuis compared after normalisation — scheme and host case-insensitively, a default port dropped, query and fragment ignored. Aniatmore than 60 seconds in the future is always refused; a staleiatis refused too, unless the proof carries a valid server nonce, which establishes the creation time instead.jtiis single-use per key: the samejtipresented twice by the same key is refused as a replay. Mint a fresh one per request.nonce— see below. When a proof carries one it must be a nonce this issuer minted and still fresh, whether or not one was required.athis required when you present the proof with an access token at the UserInfo endpoint:base64url(SHA-256(ASCII(access_token))).
The htu is the URL as the issuer publishes it — the issuer host plus the request path. On a request that arrives on the mutual TLS listener, that is the aliased base from mtls_endpoint_aliases, so build htu from the endpoint URL you actually called.
Where to send it
Send a proof to the endpoints your client authenticates to or presents a token at: the token endpoint, PAR, introspection, and the UserInfo endpoint.
- At PAR, a proof (or the
dpop_jktparameter, RFC 9449 §10) binds the authorization code to that key. Send both and they must name the same key, or the push is refusedinvalid_request. The code can then only be redeemed with a proof for that key — anything else isinvalid_grant. - At the token endpoint, the proof binds the issued access token (
cnf.jkt) and, withoffline_access, the refresh-token family. - At the UserInfo endpoint, present the token with the
DPoPauthorization scheme and a proof carryingath.
The nonce retry
The issuer can require a server-provided nonce in the proof (RFC 9449 §8/§9). A request whose proof lacks one — or carries a stale one — is answered:
HTTP/1.1 400 Bad Request
DPoP-Nonce: <fresh nonce>
Content-Type: application/json
{ "error": "use_dpop_nonce", "error_description": "A server-provided nonce is required in the DPoP proof" }At the UserInfo endpoint the same condition is a 401 with a WWW-Authenticate: DPoP … challenge and the same DPoP-Nonce header.
Handle it as a retry, not an error: take the value from the DPoP-Nonce header, put it in the proof's nonce claim, mint a fresh jti, and repeat the request once. A refused proof does not burn its jti, so the retry may reuse it — but a fresh one is simpler. Every successful token response to a DPoP-bound request also carries a DPoP-Nonce, so a client that stores the latest value it was handed normally never needs the round trip.
A FAPI application must carry a nonce at the token endpoint and at the UserInfo endpoint. Nowhere is a nonce required of any other application — but a nonce that is present must be ours and fresh on every endpoint, so always send the latest value you were handed rather than an old one.
What comes back
{
"access_token": "<jwt>",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "openid profile",
"refresh_token": "<opaque>"
}The access token carries "cnf": { "jkt": "<thumbprint>" }. A DPoP-bound token must be presented with the DPoP scheme — presenting it as Bearer is refused.
Refresh tokens are bound too. For a public (SPA) client the family is pinned to the key it was issued with: a refresh proved with another key is refused invalid_grant before the token is spent. For a confidential client the client authentication already constrains the exchange, so the recorded binding is a record rather than a gate — but the access token minted on a refresh always carries the cnf of the key that request demonstrated.
Mutual TLS
Mutual TLS covers two things at once, and they are registered independently:
- Client authentication (RFC 8705 §2) —
tls_client_authorself_signed_tls_client_authas the application'stoken_endpoint_auth_method, instead of a secret or an assertion. - Certificate-bound tokens (RFC 8705 §3) —
tls_client_certificate_bound_access_tokens, which putscnf["x5t#S256"]on the access token. A client using any authentication method can enable this and present a certificate for the binding.
Both need a deployment whose discovery document publishes mtls_endpoint_aliases (together with tls_client_certificate_bound_access_tokens: true). Where it does not, registering either method or the binding switch is refused 422 with code: "mtls.listener_unavailable". DPoP has no such dependency.
Call the aliased endpoints
Client certificates are terminated on a separate listener, so the endpoints have a second set of URLs. Read them from discovery and use them for every request that carries a certificate:
{
"mtls_endpoint_aliases": {
"token_endpoint": "https://acme.example.com:8445/v1/idp/oidc/token",
"revocation_endpoint": "https://acme.example.com:8445/v1/idp/oidc/revoke",
"introspection_endpoint": "https://acme.example.com:8445/v1/idp/oidc/introspect",
"pushed_authorization_request_endpoint": "https://acme.example.com:8445/v1/idp/oidc/par",
"backchannel_authentication_endpoint": "https://acme.example.com:8445/v1/idp/oidc/bc-authorize",
"userinfo_endpoint": "https://acme.example.com:8445/v1/idp/oidc/userinfo"
}
}The authorization endpoint is not aliased — it is browser-facing and takes no client certificate. Push your request to the aliased PAR endpoint and send the browser to the ordinary authorization_endpoint with the request_uri.
The port above is illustrative. Resolve the aliases from the tenant's discovery document; never hard-code them.
The two methods
PKI-based (RFC 8705 §2.1). Register both:
tls_client_auth_subject_dn— the expected subject DN of your leaf certificate, in RFC 4514 form. It is normalised on save, and the presented leaf's subject must match it.tls_client_auth_ca_pem— the trust anchor(s) your certificate chains to, as PEM. The issuer runs the path validation itself, so the anchors are yours, not a platform trust store.
Registering one without the other is refused (mtls.subject_dn_required / mtls.trust_anchors_required), and tls_client_certificate_pem is not accepted for this method (mtls.certificate_not_applicable).
An anchor must be a CA certificate (anchors.not_ca); a bundle holds at most 10 anchors (anchors.too_many) and 64 KB. A chain may carry at most four intermediates between your leaf and an anchor. An anchor that has expired is accepted — a "keep both" bundle of an expiring root and its renewal is the way to roll an anchor without a gap.
Self-signed (RFC 8705 §2.2). Register the certificate itself:
tls_client_certificate_pem— exactly one PEM certificate, at most 16 KB.
The presented leaf is compared against it byte for byte, and must be inside its validity window at the time of the request. The PKI fields are not accepted for this method (mtls.pki_fields_not_applicable), and omitting the certificate is mtls.certificate_required.
On either method the certificate is checked at registration, not just at use: an expired or not-yet-valid certificate (cert.expired / cert.not_yet_valid), an RSA key under 2048 bits or an EC curve other than P-256/P-384 (cert.key_too_small), a key that is neither RSA nor EC (cert.key_type), and a certificate whose extended key usage excludes TLS client authentication (cert.usage) are all refused.
client_id still travels
The certificate is the credential, but it is not how the client is located: send client_id in the request body on every mutual TLS request. Without it the request is invalid_client.
Registration fields for mutual TLS are only valid with a mutual TLS authentication method — sending them for a private_key_jwt or secret-based application is refused mtls.fields_require_mtls_method.
A mutual TLS application may also register jwks / jwks_uri. Those keys are not used for client authentication — they verify its signed request objects (JAR at PAR and the CIBA backchannel request). A FAPI application with a CIBA delivery mode must have them (fapi_ciba.requires_signing_keys).
What comes back
A certificate-bound access token stays a Bearer token — RFC 8705 defines no new authorization scheme — and carries "cnf": { "x5t#S256": "<thumbprint>" }. Present it with the Bearer scheme, over a connection that carries the same certificate, to an aliased endpoint.
If your application registered certificate binding, a token request without a client certificate is refused invalid_request: the registration is a promise the request has to keep.
Resource-server checklist
If your own API accepts SenseCrypt access tokens, sender-constraint moves work onto your side. SenseCrypt enforces the binding on its own protected endpoints (for example, the UserInfo endpoint); for your API, you must:
- Read
cnf. After validating the signature,iss,audandexpas usual, look forcnf. A token with nocnfis a bearer token; a token with one is only presentable by the holder of that key. - Refuse a bound token presented as a plain bearer token. A
cnf.jkttoken arriving with theBearerscheme must be refused (RFC 9449 §7.2) — this is the check the whole mechanism rests on. Answer401withWWW-Authenticate: DPoP error="invalid_token". - Verify the proof yourself for
cnf.jkt: theDPoPheader's own signature,typ,alg, thehtm/htuof your endpoint, a freshiat, anathmatching the token you were handed, and thejwkthumbprint equal tocnf.jkt. Keep a short-livedjticache to refuse replays. - Compare the certificate for
cnf["x5t#S256"]: derivebase64url(SHA-256(DER))from the client certificate on the connection and compare. If your API cannot see the client certificate, it cannot honour a certificate-bound token. - Or let introspection do it. Introspecting a bound token returns its
cnf, andtoken_type: "DPoP"for a DPoP-bound one, so a resource server that validates by introspection gets the binding without parsing the JWT. A DPoP proof on the introspection call itself is optional. - Treat the
DPoPauthorization scheme as case-insensitive (DPoP,dpop,DPOPare one scheme).
Related
- Security profiles — where sender-constraint becomes mandatory, and the registration rules.
- Signed requests and responses — JAR at PAR and JARM.
- Tokens & sessions — the base token shapes and refresh behaviour.
- Validate tokens — the JWKS validation recipe.
- Error codes —
invalid_dpop_proof,use_dpop_nonce, and the registration rule codes.
Back-channel logout
Receive OIDC Back-Channel Logout tokens — registering the URL, choosing which session endings you are told about, the logout token's claims, delivery and retries, and the checks your endpoint must make.
Signed requests and responses
JAR request objects at the PAR endpoint (RFC 9101) and JARM signed authorization responses — the signing rules, the claims that are required, where each is accepted, and how to validate the response JWT.