- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)Fett et al. (IETF, September 2023) - the spec this page unpacks; sections 4-8 carry the mechanism
- RFC 8705: mTLS Client Authentication and Certificate-Bound TokensCampbell, Bradley, Sakimura, Lodderstedt (IETF, February 2020) - the comparison section
- RFC 7800: Proof-of-Possession Key Semantics for JWTsthe
cnfclaim bothjktandx5t#S256live under - RFC 7638: JWK Thumbprintthe hash behind
jkt- one canonical JSON serialization, then SHA-256 - Attestation-based client authentication (IETF draft)the platform-attestation route, still a draft - named in §06, deliberately not deeper
- The bearer threat listthe replay row this mechanism closes, on the page DPoP answers
RFC 9449 (Fett, Campbell, Bradley, Lodderstedt, Jones, Waite; September 2023) defines DPoP: "an application-level mechanism for sender-constraining OAuth [RFC6749] access and refresh tokens" (section 1). The client mints its own key pair - no certificate, no PKI - and proves possession on every call: each token request and each protected request carries a DPoP header whose value is a short-lived signed JWT. The authorization server binds what it issues to the public key inside that proof; the resource server then demands a fresh proof signed by the same key on every request. The result is the constraint bearer never had: "the legitimate presenter of the token is constrained to be the sender that holds and proves possession of the private part of the key pair" (section 1).
The RFC's motivation is exactly the failure mode thebearer threat listends on: a captured token used at a different endpoint. DPoP is its "accessible application-layer means" of prevention (section 2), aimed at the clients TLS client certificates cannot reach - SPAs and native apps. Two boundaries come with it: DPoP is not a way to authenticate the client - "The DPoP mechanism presented herein is not a client authentication method" (section 3) - and it "does not directly ensure message integrity, but it relies on the TLS layer for that purpose" (section 3).
A DPoP proof is a JWT typed like anywell-run access token should be:typ is dpop+jwt, the explicit typing RFC 8725 recommends (section 4.2). alg MUST be an asymmetric signature algorithm - none and symmetric MACs are out. And the header carries the client's public key itself in jwk, which is what makes the scheme self-contained: the verifier needs no key directory, the signature simply "verifies with the public key contained in thejwk JOSE Header Parameter" (section 4.3).
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIiwieCI6Imw4dEZyaHgtMzR0VjNoUklDUkRZOXpDa0RscEJoRjQyVVFVZldWQVdCRnMiLCJ5IjoiOVZFNGpmX09rX282NHpiVFRsY3VOSmFqSG10NnY5VERWclUwQ2R2R1JEQSIsImNydiI6IlAtMjU2In19.<payload>.<signature>
And what the client signed - the minimal set, plus the two conditional claims (ath once there is an access token, nonceonce the server challenges for one):
// header (the segment above, decoded - the RFC's own example key)
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA",
"crv": "P-256"
}
}
// payload (fresh for every request; jti/htm/htu/iat values are the RFC's)
{
"jti": "-BwC3ESc6acc2lTc",
"htm": "POST",
"htu": "https://server.example.com/token",
"iat": 1562262616,
"ath": "<base64url-sha-256-of-the-access-token>",
"nonce": "<server-issued-value>"
}| Claim | Status | Means |
|---|---|---|
typ header, 4.2 | MUST be dpop+jwt | Explicit typing, per RFC 8725 §3.11 - the same confusion-attack discipline as at+jwt |
alg header, 4.2 | MUST be asymmetric | "MUST NOT be none or an identifier for a symmetric algorithm" - proof of possession means a private key, not a shared secret |
jwk header, 4.2 | REQUIRED | The public key the client chose; "MUST NOT contain a private key" (check 7 of the server's list, §4.3) |
jti | REQUIRED | Unique per proof: at least "96 bits of pseudorandom data" or a UUIDv4 - the anchor for the server's replay detection (§4.2, §11.1) |
htm | REQUIRED | The HTTP method of the request being signed - a GET proof will not POST |
htu | REQUIRED | The target URI "without query and fragment parts" (4.2) - see the canonicalization gotcha below |
iat | REQUIRED | Creation timestamp; the server checks it against an acceptable window (4.3, check 11) |
ath | MUST at resource access 4.2, 7 | Base64url of the SHA-256 "hash of the ASCII encoding of the associated access token's value" - the proof and this token are now one package |
nonce | MUST when challenged 4.2, 8 | The server-issued value from a DPoP-Nonce response header - the replay tightening in §04 below |
The htu gotcha: only the method and URI are signed - "the idea is to sign just enough of the HTTP data to provide reasonable proof of possession" - and the URI sheds its query and fragment, because the design "is to avoid the substantial difficulties inherent in attempting to normalize HTTP messages" (section 4.2). The consequence cuts both ways: the proof covers scheme, authority, and path and nothing after them, while the server "SHOULD employ syntax-based normalization" and "scheme-based normalization" before comparing the claim - the RFC's own rationale being to "reduce the likelihood of false negatives" (section 4.3). Sign what the spec defines; compare with the normalization the spec names - anything stricter on either side fails honest requests.
The client puts a proof in the DPoP header of its token request - any grant, "regardless of grant type" (section 5). An invalid proof earnsinvalid_dpop_proof; a valid one makes the AS associate the minted token with the proof's public key, and the response announces the binding: "A token_type of DPoP MUST be included in the access token response to signal to the client that the access token was bound to its DPoP key" (section 5).
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "<access-token-value>",
"token_type": "DPoP",
"expires_in": 2677,
"refresh_token": "<refresh-token-value>"
}For JWT access tokens the binding rides in the payload: the cnfconfirmation claim from RFC 7800, under a new jkt member whose value "MUST be the base64url encoding... of the JWK SHA-256 Thumbprint (according to [RFC7638]) of the DPoP public key" (section 6). The token no longer names a bearer; it names a key. The same thumbprint reaches non-JWT tokens as a top-level member of the introspection response (section 6.2).
// a bound JWT access token's payload (RFC 9449 section 6 example)
{
"sub": "[email protected]",
"iss": "https://server.example.com",
"nbf": 1562262611,
"exp": 1562266216,
"cnf":
{
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
}
}At the resource server the scheme changes - Authorization: DPoPinstead of Bearer (section 7.1) - and every request carries a fresh proof with ath binding it to this exact token. The pairing is what closes the swap attack: without ath, "a captured signature applied to AT1 could be replayed with AT2 instead, changing the rights and access of the intended request" (section 7). And the downgrade door is shut explicitly: a protected resource "MUST reject a DPoP-bound access token received as a bearer token" (section 7.2) - thebearer rules no longer apply to this token, by design.
Left alone, a proof's lifetime is bounded only by the server'siat window - and a party controlling the client "can create DPoP proofs for use arbitrarily far in the future" (section 8): mint proofs today, exfiltrate them with a stolen token, spend them next month. The RFC's answer is an opaque, server-chosen nonce that "can be used to limit the lifetime of DPoP proofs" (section 8). The choreography is a two-step dance the client learns on its first request:
HTTP/1.1 400 Bad Request
DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v
{
"error": "use_dpop_nonce",
"error_description":
"Authorization server requires nonce in DPoP proof"
}The client retries with the value in the proof's nonce claim, and the request now expires with the server's nonce policy instead of drifting into the future. Three rules make it robust: "Nonce values MUST be unpredictable" (section 8); a mismatch is answered with a fresh challenge, so the mechanism is self-correcting (section 8); and the same error dance exists at the resource server viaWWW-Authenticate: DPoP error="use_dpop_nonce" (section 9). One browser-shaped footnote: in an SPA the challenge headers are invisible to JavaScript unless the server lists them inAccess-Control-Expose-Headers - the RFC says so for bothDPoP-Nonce and WWW-Authenticate (sections 7.1, 8).
Here is the detail explainers get wrong: DPoP is not just an access-token mechanism. The title of the RFC's own introduction says it constrains "access and refresh tokens" (section 1), and section 5 is blunt about the public-client case: "the refresh token MUST be bound to the respective public key. The binding MUST be validated when the refresh token is later presented to get new access tokens" (section 5). For exactly the clients with no other constraint - the SPA that cannot keep a secret - the long-lived credential, the one worth the most, is in scope.
Confidential clients are the deliberate exception, not an oversight: their refresh tokens "are not bound to the DPoP proof public key because they are already sender-constrained with a different existing mechanism" (section 5) - client authentication. The RFC even gives the reason it stays that way: "This existing sender-constraining mechanism is more flexible (e.g., it allows credential rotation for the client without invalidating refresh tokens) than binding directly to a particular public key" (section 5). Binding a confidential client's refresh token to one DPoP key would trade rotation freedom for nothing.
The same section holds a quiet deployment story: an AS MAY still issue unbound access tokens (token_type: Bearer), and for a public client that also holds a refresh token, "this has the effect of DPoP-binding the refresh token alone" (section 5) - the most valuable credential is protected even before every resource server learns DPoP.
DPoP is the application-layer half of OAuth's sender-constraining pair. The other half is RFC 8705(Campbell, Bradley, Sakimura, Lodderstedt; February 2020): the client authenticates with an X.509 certificate over mutual TLS, and certificate-bound "access and refresh tokens" carry the certificate's SHA-256 thumbprint in the samecnf claim - under the x5t#S256 member (section 3). The resource server's check needs no JWT verification at all: the proof is the TLS handshake itself, and the token only works on connections that present the same certificate.
| mTLS (RFC 8705) | DPoP (RFC 9449) | |
|---|---|---|
| Key material | X.509 client certificate - PKI-issued or self-signed (section 2) | App-generated key pair, per client - WebCrypto-friendly (section 1) |
| How possession is proved | The mutual-TLS handshake on every connection; the key never touches application code | A signed JWT header on every token and resource request; the key lives in the app |
| The binding claim | cnf.x5t#S256 - SHA-256 of the certificate (section 3) | cnf.jkt - SHA-256 thumbprint of the public key (section 6) |
| What gets bound | Access tokens; refresh tokens SHOULD also bind (section 4) | Access tokens; refresh tokens for public clients MUST (section 5) |
| Natural habitat | Server-to-server and enterprise: open banking, PSD2, FAPI - where certificates already exist | Browsers and native apps - where certificates are impractical (section 1) |
| Operational cost | Certificate lifecycle and PKI; TLS termination needs care so the client cert survives to the token logic (section 6.5) | Little beyond signing - but replay defense is the server's discipline: jti, iat, nonces |
A third route is still being written: attestation-based public clients, where the client proves properties of its platform - a key locked in secure hardware that no page script can extract. As of October 2026 that work lives in individual IETF drafts (draft-ietf-oauth-attestation-based-client-authand siblings), not an RFC - which is why this paragraph is the whole deep dive it gets. When it matures, it attacks DPoP's honest limit from §07: the key that signs is only as safe as the context that holds it.
| Failure | What happens | The fix (and its clause) |
|---|---|---|
| Code in the client | XSS or a compromised dependency signs its own proofs with the client's key - "That execution context has access to utilize the signing key" (section 2) | Nothing client-side; the RFC's own boundary: "no feasible defense against this threat except generally preventing XSS; therefore, it is considered out of scope for DPoP" (§2) - architecture is the answer (RFC 10017's ranking) |
| Proof replay inside the window | A captured proof is re-presented before its iat window closes - "the ath field alone does not prevent replay of the DPoP proof" (section 7) | Server-side jti tracking plus the nonce mechanism (§8) to bound proof lifetime |
| Future-dated proofs | Proofs minted today for use "arbitrarily far in the future" ride alongside an exfiltrated token (section 8) | The server nonce challenge (§8) - proofs expire with the server's nonce, not the client's patience |
| Bearer downgrade | A DPoP-bound token is presented as Bearer to a resource server that never heard of DPoP - and accepted | A server supporting both schemes "MUST reject a DPoP-bound access token received as a bearer token" (§7.2); unaware servers are a migration risk, which the RFC's authors accept for phased rollouts (§7.2) |
| htu comparison drift | A server compares the raw request URI - query string and all - against an htu that legitimately has neither | Normalize per §4.3: strip query and fragment (§4.2) and apply syntax/scheme-based normalization before comparing |
| DPoP as client auth | The DPoP header is read as authenticating the client; keys are client-chosen, so anyone can claim any identity | "The DPoP mechanism presented herein is not a client authentication method" (§3); the client authenticates (or registers public) exactly as before |