ndovuoauth2

SPEC PRIMER · RFC 9449 · SEPTEMBER 2023

DPoP: proving possession

Demonstrating proof of possession: the signed proof JWT, the cnf/jkt binding, and what DPoP stops and what it leaves open.

October 28, 2026possession, not presentationWhere bearer hands off
01 · What DPoP standardizes

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).

02 · The proof on the wire

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).

One proof, riding on every request
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>"
}
ClaimStatusMeans
typ header, 4.2MUST be dpop+jwtExplicit typing, per RFC 8725 §3.11 - the same confusion-attack discipline as at+jwt
alg header, 4.2MUST 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.2REQUIREDThe public key the client chose; "MUST NOT contain a private key" (check 7 of the server's list, §4.3)
jtiREQUIREDUnique per proof: at least "96 bits of pseudorandom data" or a UUIDv4 - the anchor for the server's replay detection (§4.2, §11.1)
htmREQUIREDThe HTTP method of the request being signed - a GET proof will not POST
htuREQUIREDThe target URI "without query and fragment parts" (4.2) - see the canonicalization gotcha below
iatREQUIREDCreation timestamp; the server checks it against an acceptable window (4.3, check 11)
athMUST at resource access 4.2, 7Base64url 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
nonceMUST when challenged 4.2, 8The 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.

03 · Binding tokens to the key

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).

The response that says "bound"
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).

What "bound" looks like in the token
// 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.

04 · The nonce mechanism

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:

The challenge (the RFC's own example, §8)
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).

05 · The refresh-token nuance

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.

06 · DPoP vs mTLS

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 materialX.509 client certificate - PKI-issued or self-signed (section 2)App-generated key pair, per client - WebCrypto-friendly (section 1)
How possession is provedThe mutual-TLS handshake on every connection; the key never touches application codeA signed JWT header on every token and resource request; the key lives in the app
The binding claimcnf.x5t#S256 - SHA-256 of the certificate (section 3)cnf.jkt - SHA-256 thumbprint of the public key (section 6)
What gets boundAccess tokens; refresh tokens SHOULD also bind (section 4)Access tokens; refresh tokens for public clients MUST (section 5)
Natural habitatServer-to-server and enterprise: open banking, PSD2, FAPI - where certificates already existBrowsers and native apps - where certificates are impractical (section 1)
Operational costCertificate 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.

07 · How it goes wrong
FailureWhat happensThe fix (and its clause)
Code in the clientXSS 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 windowA 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 proofsProofs 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 downgradeA DPoP-bound token is presented as Bearer to a resource server that never heard of DPoP - and acceptedA 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 driftA server compares the raw request URI - query string and all - against an htu that legitimately has neitherNormalize per §4.3: strip query and fragment (§4.2) and apply syntax/scheme-based normalization before comparing
DPoP as client authThe 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
08 · Primary sources