privileged.id

OAuth 2.0

Sender-constrained tokens: DPoP and mTLS

Turning stolen tokens into useless strings: how DPoP proofs and certificate-bound tokens work, verified against the RFC examples.

Reviewed 7 min read 4 primary sources

A bearer token is like cash: whoever holds it can spend it. A sender-constrained token is more like a card that only works with its owner's PIN: presenting it requires proof of possession of a key that never leaves the legitimate client. OAuth has two standardized ways to do this, DPoP (RFC 9449) at the application layer and mutual-TLS certificate-bound tokens (RFC 8705) at the transport layer. RFC 9700 §2.2.1 says authorization and resource servers SHOULD use one of them.

Why bearer tokens leak

RFC 9700 §4.10 starts from an uncomfortable premise: access tokens get stolen "in various ways", and the specific defenses against each route are sometimes missing or implemented incorrectly. The routes it catalogues include:

  • Leaky redirects and open redirectors sending tokens or codes to attackers (§4.1, §4.11).
  • Referer headers and browser history (§4.2, §4.3).
  • Mix-up attacks that trick the client into sending credentials to the wrong server (§4.4).
  • Counterfeit or compromised resource servers that receive tokens legitimately and replay them elsewhere (§4.9).

Add the operational ones every team has seen: tokens in application logs, in crash reports, in a proxy's debug output, in a support ticket. With a bearer token, any of these is a working credential until it expires. With a sender-constrained token, the stolen string alone is useless.

The mechanism in both standards follows the outline of RFC 9700 §4.10.1: the authorization server binds the token to key material known to the client; the key is established either before the binding (an X.509 certificate) or when the token is requested (a DPoP key pair); and the resource server checks proof of possession of that key on every request, including protection against replay of the proof.

DPoP (RFC 9449)

DPoP, "Demonstrating Proof of Possession" (September 2023), has the client generate an asymmetric key pair and sign a small JWT, the DPoP proof, for every HTTP request it makes to the authorization server and to resource servers. The authorization server binds issued tokens to the public key; the resource server checks that each request carries a fresh proof signed by that key.

DPoP: binding an access token to a client key (RFC 9449) Client holds private key Authorization server Resource server 1. Generate key pair (once) 2. POST /token + DPoP proof 3. Verify proof; bind token: cnf.jkt 4. token_type: DPoP 5. New proof: htm, htu, iat, jti, ath 6. Authorization: DPoP <token> + DPoP: <proof> 7. Check proof, ath, key = cnf.jkt 8. Response
The private key stays in the client. Every request carries a new proof that covers the HTTP method, the URL and, at the resource server, a hash of the access token.

The proof JWT

The decoded proof from RFC 9449 Figure 4, which this page re-verified (the ES256 signature in the RFC's encoded example checks out against the embedded public key):

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA",
    "crv": "P-256"
  }
}
.
{
  "jti": "-BwC3ESc6acc2lTc",
  "htm": "POST",
  "htu": "https://server.example.com/token",
  "iat": 1562262616
}
typ = dpop+jwt
Explicit typing (RFC 8725 §3.11). Servers MUST check it, so no other kind of JWT can be passed off as a proof (§11.5).
alg
An asymmetric signature algorithm; MUST NOT be none or a MAC algorithm.
jwk
The client's public key, which MUST NOT contain a private key. Its JWK SHA-256 thumbprint (RFC 7638) is what tokens are bound to.
jti
A unique ID for this proof (for example at least 96 random bits), used for replay detection.
htm, htu
The HTTP method and target URI of the request, without query or fragment. A proof for POST /token cannot be reused for GET /accounts.
iat
Creation time. Servers MUST accept proofs only for a limited time, preferably seconds or minutes (§11.1).
ath
Required when an access token is presented: the base64url SHA-256 hash of the access token's ASCII value. It ties the proof to one token.
nonce
Required when the server has supplied one in a DPoP-Nonce header. Not to be confused with the OpenID Connect nonce.

Getting a DPoP-bound token

The client sends the proof in a DPoP header on its token request (any grant type). The authorization server validates the proof, binds the token to the key, and signals the binding with token_type DPoP, which it MUST include (§5):

POST /token HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6Ik...

grant_type=authorization_code&client_id=s6BhdRkqt3&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU",
  "token_type": "DPoP",
  "expires_in": 2677,
  "refresh_token": "Q..Zkm29lexi8VnWg2zPW1x-tgGad0Ibc3s3EwM_Ni4-g"
}

For a public client, the refresh token MUST also be bound to the key, and each refresh MUST present a proof for the same key (§5). That satisfies RFC 9700's requirement to sender-constrain or rotate public clients' refresh tokens. If the response comes back with token_type Bearer, the access token is not bound; a client that depends on the protection MUST discard the response.

For a JWT access token, the binding is a cnf (confirmation) claim holding the key's thumbprint. For the RFC's example key this page recomputed:

"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }

For opaque tokens, the same cnf.jkt appears in the introspection response.

Calling the resource server

GET /protectedresource HTTP/1.1
Host: resource.example.org
Authorization: DPoP Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6Ik...

The new proof's payload is {"jti":"e1j3V_bKic8-LAEB","htm":"GET","htu":"https://resource.example.org/protectedresource","iat":1562262618,"ath":"fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"}; the ath value is SHA-256 of the access token above, recomputed for this page. The resource server MUST run the proof checks of §4.3 (single header, well-formed JWT, required claims, typ, acceptable asymmetric alg, valid signature with the embedded key, no private key, matching htm and htu, nonce if one was issued, acceptable age, correct ath) and confirm the proof's key matches the key the token is bound to. It MUST NOT grant access unless all checks pass (§7.1).

Replay, nonces and code binding

  • Proof replay. A captured proof can be replayed only at the same method and URL, and only within the acceptance window. Servers can also remember jti values for the window and reject repeats; this is strong but hard across many servers without shared state (§11.1).
  • Server nonces. An authorization or resource server MAY require a server-supplied nonce, replying with use_dpop_nonce and a DPoP-Nonce header. Nonces limit how far in the future an attacker who briefly controls the client can pre-generate proofs, and avoid trusting client clocks (§8, §9).
  • Authorization code binding. The optional dpop_jkt parameter in the authorization request binds the code itself to the key's thumbprint, giving end-to-end binding of the flow (§10). With PAR, sending a DPoP header on the PAR request has the same effect.

What DPoP does not do

DPoP is not client authentication, and a valid proof alone is not an access-control decision (§4). It does not protect message bodies or headers other than the method and URL; it relies on TLS for integrity. And it cannot help if an attacker runs code inside the client: §11.4 explains that XSS can use a non-exportable key to mint proofs while the client is online, or start a silent new authorization in an iframe and bind the resulting tokens to the attacker's own key. Content Security Policy and ordinary XSS prevention remain essential for browser apps.

Mutual-TLS certificate-bound tokens (RFC 8705)

With RFC 8705 (February 2020), the client presents an X.509 certificate in a mutual-TLS handshake at the token endpoint. The authorization server binds the issued token to that certificate's SHA-256 thumbprint. At the resource server the client must use mutual TLS with the same certificate, and the resource server MUST obtain the certificate from its TLS layer and verify it matches the token, rejecting a mismatch with 401 and invalid_token (§3).

{
  "iss": "https://server.example.com",
  "sub": "ty.webb@example.com",
  "exp": 1493726400,
  "nbf": 1493722800,
  "cnf": {
    "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
  }
}

From RFC 8705 Figure 2. The x5t#S256 value is the base64url SHA-256 hash of the DER-encoded certificate, with no padding (§3.1). The RFC's sample value is illustrative: decoding and re-encoding it does not give back the same string, so it is not the canonical encoding of any real hash. Do not use it as a test vector.

  • Client authentication and binding are separate. The same RFC defines tls_client_auth and self_signed_tls_client_auth for authenticating confidential clients, but certificate-bound tokens can also be used by public clients with a self-signed, per-instance certificate (§4). For those, the server SHOULD bind refresh tokens to the certificate as well.
  • Certificate rotation invalidates tokens. A new certificate means new tokens, usually via a refresh (§6.3).
  • The resource server must know in advance that it needs to request a client certificate, because the token arrives only after the TLS handshake (§3). Deployments often put mTLS resources on separate host names.
  • TLS termination. If a load balancer terminates TLS and forwards the client certificate in a header, that header becomes the security boundary. RFC 8705 leaves how it is passed out of scope (§6.5); RFC 9700 §4.13 says the proxy MUST sanitize inbound headers so an attacker cannot supply their own.
  • No implicit grant. Binding tokens issued at the authorization endpoint is explicitly out of scope (§6.4).

Which one to use

DPoP (RFC 9449)mTLS-bound (RFC 8705)
LayerApplication: signed JWT per requestTransport: TLS client certificate
Key setupClient generates a key pair at runtime; no PKICertificate per client (CA-issued or self-signed and registered)
Fits bestBrowser apps, mobile apps, public clientsServer-to-server, regulated APIs, existing PKI
Works in browsers?Yes, with Web Crypto (non-extractable keys possible)Rarely practical; client certificates in browsers are a poor user experience
Through TLS-terminating proxiesYes; proof is in an HTTP headerNeeds trusted certificate forwarding
Token in token responsetoken_type: DPoP; cnf.jkttoken_type: Bearer usually; cnf.x5t#S256
Replay protectionPer-request proof, time window, optional jti cache and server nonceTLS handshake proves the key on each connection
Also authenticates the client?No (combine with any method)Optionally, via tls_client_auth

RFC 9449 §11 notes that TLS-based methods can achieve stronger message integrity and replay protection through their tight integration with the TLS layer, while DPoP binds only to the method, URL and time. In practice: choose mTLS where both ends are servers you control and a PKI or certificate registration already exists; choose DPoP for public clients and anywhere TLS is terminated by infrastructure you cannot extend.

Checklist

  • Authorization server advertises dpop_signing_alg_values_supported and/or tls_client_certificate_bound_access_tokens in metadata.
  • Clients that rely on binding check token_type and discard unbound responses.
  • Resource servers verify every proof or certificate, and reject bound tokens sent as Bearer.
  • Short proof acceptance window; jti replay cache or server nonces where feasible.
  • Private keys non-exportable where the platform allows (hardware-backed keystores, non-extractable Web Crypto keys).
  • Proxies sanitize certificate and forwarding headers.
  • Public clients' refresh tokens bound to the same key.
  • Sender-constraining treated as one layer: audience restriction, short lifetimes and XSS defenses still apply.

Sources

  1. RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  2. RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  3. RFC 9700: Best Current Practice for OAuth 2.0 Security, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  4. RFC 7519: JSON Web Token (JWT), IETF. IETF Trust Legal Provisions (brief quotations with attribution).