SenseCrypt Docs
Guides

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:

MechanismRegistration switchcnf membertoken_type
DPoP (RFC 9449) — a per-request proof JWT signed with the client's own keydpop_bound_access_tokensjkt — the RFC 7638 thumbprint of the proof keyDPoP
Mutual TLS (RFC 8705) — the client's TLS certificatetls_client_certificate_bound_access_tokensx5t#S256 — base64url SHA-256 of the leaf certificate's DERBearer

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": "…"
}
  • typ must be dpop+jwt, and alg must be ES256 or PS256 (the advertised dpop_signing_alg_values_supported). none and the HMAC algorithms are refused.
  • jwk is the public key, in the JOSE header. A jwk carrying private members (d, p, q, …) is refused.
  • jti, htm, htu and iat are required. htm is compared case-insensitively against the request method; htu is compared after normalisation — scheme and host case-insensitively, a default port dropped, query and fragment ignored. An iat more than 60 seconds in the future is always refused; a stale iat is refused too, unless the proof carries a valid server nonce, which establishes the creation time instead.
  • jti is single-use per key: the same jti presented 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.
  • ath is 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_jkt parameter, RFC 9449 §10) binds the authorization code to that key. Send both and they must name the same key, or the push is refused invalid_request. The code can then only be redeemed with a proof for that key — anything else is invalid_grant.
  • At the token endpoint, the proof binds the issued access token (cnf.jkt) and, with offline_access, the refresh-token family.
  • At the UserInfo endpoint, present the token with the DPoP authorization scheme and a proof carrying ath.

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:

  1. Client authentication (RFC 8705 §2) — tls_client_auth or self_signed_tls_client_auth as the application's token_endpoint_auth_method, instead of a secret or an assertion.
  2. Certificate-bound tokens (RFC 8705 §3) — tls_client_certificate_bound_access_tokens, which puts cnf["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:

  1. Read cnf. After validating the signature, iss, aud and exp as usual, look for cnf. A token with no cnf is a bearer token; a token with one is only presentable by the holder of that key.
  2. Refuse a bound token presented as a plain bearer token. A cnf.jkt token arriving with the Bearer scheme must be refused (RFC 9449 §7.2) — this is the check the whole mechanism rests on. Answer 401 with WWW-Authenticate: DPoP error="invalid_token".
  3. Verify the proof yourself for cnf.jkt: the DPoP header's own signature, typ, alg, the htm/htu of your endpoint, a fresh iat, an ath matching the token you were handed, and the jwk thumbprint equal to cnf.jkt. Keep a short-lived jti cache to refuse replays.
  4. Compare the certificate for cnf["x5t#S256"]: derive base64url(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.
  5. Or let introspection do it. Introspecting a bound token returns its cnf, and token_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.
  6. Treat the DPoP authorization scheme as case-insensitive (DPoP, dpop, DPOP are one scheme).

On this page