Validating DPoP proofs: which replays jti, htm, htu and nonce stop, and which they don't
What a server must check when it receives an OAuth 2.0 DPoP proof JWT, and how far jti, htm, htu, iat and nonce go in limiting replay, based on RFC 9449 alone.
A client using DPoP (Demonstrating Proof of Possession) sends a short JWT in a DPoP header with each request. That JWT is called a DPoP proof. It is signed with the client’s private key, and its header carries the matching public key. If the server checks the signature and stops there, all the proof tells it is that someone holding this key made this JWT at some point. Whether the proof was made for this particular request only becomes clear once the server checks jti, htm, htu, iat and nonce.
This article relies on a single source: RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) (September 2023, Standards Track), checked on 2026-10-28. Details of the documents RFC 9449 references, such as the JWT, JWK and HTTP semantics specifications, are out of scope here. So is how any particular library or service implements these rules.
What DPoP does and doesn’t do
The RFC 9449 abstract describes DPoP as a mechanism for sender-constraining OAuth 2.0 tokens through application-level proof of possession, and says it allows replay attacks with access and refresh tokens to be detected. Because a token is bound to a specific public key, someone who has stolen only the token can’t produce a proof signed with that key.
Two things are outside its scope from the start.
- It isn’t client authentication. Section 1 says “DPoP itself is not used for client authentication”, and Section 3 repeats that the mechanism is not a client authentication method.
- It doesn’t protect the integrity of request bodies or headers. Section 11.7 explains that a proof only covers the HTTP method and URI. An attacker who can modify message contents or headers may also be able to replay requests. The same section notes that TLS usually provides sufficient protection here, and Section 2 requires that DPoP always be used together with HTTPS (MUST).
What goes into a proof JWT
Section 4.2 defines the structure. All three JOSE header fields are required.
| Header field | Requirement |
|---|---|
typ |
The value dpop+jwt |
alg |
A registered asymmetric signature algorithm. MUST NOT be none or a symmetric (MAC) algorithm identifier |
jwk |
The client’s public key. MUST NOT contain a private key |
The payload claims are as follows.
| Claim | Required? | Content |
|---|---|---|
jti |
Required | Unique identifier for the proof |
htm |
Required | HTTP method of the request the JWT is attached to |
htu |
Required | Target URI of the request, without query and fragment parts |
iat |
Required | Time the JWT was created |
ath |
Required when accessing a protected resource with an access token | base64url encoding of the SHA-256 hash of the ASCII encoding of the access token value |
nonce |
Required when the server has supplied a nonce in a DPoP-Nonce header |
A recent nonce supplied by the server |
jti must have a negligible chance of colliding with any other proof in the same context. Section 4.2 gives two ways to generate it: at least 96 bits of pseudorandom data, encoded for example with base64url, or a version 4 UUID. Proofs MAY carry other header parameters or claims defined by extensions, profiles or deployment-specific requirements.
Here is the token-request example from RFC 9449, decoded. The signature is omitted. First, the JOSE header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA",
"crv": "P-256"
}
}
Then the payload:
{
"jti": "-BwC3ESc6acc2lTc",
"htm": "POST",
"htu": "https://server.example.com/token",
"iat": 1562262616
}
It has no ath or nonce because it’s a request to the token endpoint without an access token, and the server hasn’t supplied a nonce.
The twelve checks a server must make
Section 4.3 states that a server validating a proof MUST ensure all of the following, and explicitly says the checks may be performed in any order.
- There is no more than one
DPoPrequest header. - The header value is a single, well-formed JWT.
- All claims required by Section 4.2 are present.
typisdpop+jwt.algis a registered asymmetric signature algorithm, is notnone, is supported by the application and is acceptable under local policy.- The signature verifies with the public key in
jwk. jwkdoes not contain a private key.htmmatches the HTTP method of the current request.htumatches the HTTP URI of the request in which the JWT was received, ignoring query and fragment parts.- If the server has supplied a nonce to the client, the
nonceclaim matches it. - The creation time, taken from
iator from a server-managed timestamp carried innonce, falls within an acceptable window. - If the proof is presented to a protected resource together with an access token,
athequals the hash of that token, and the public key the token is bound to matches the public key in the proof.
For the htu comparison, the RFC recommends (SHOULD) syntax-based normalization (RFC 3986 Section 6.2.2) and scheme-based normalization (Section 6.2.3) to reduce false negatives. This is also why passing the signature check (item 6) can’t be a reason to skip the rest. A signature only shows possession of the key. Items 8 to 12 are what tie the proof to a specific request.
Items 4 and 5 relate to the risk of another kind of signed JWT being passed off as a proof. Section 11.5 restates that a server accepting signed proofs MUST verify that typ is dpop+jwt, and Section 11.6 says the none algorithm MUST NOT be allowed. The general problem of validating alg is covered in Why a JWT verifier shouldn’t trust alg.
What htm and htu prevent
Section 11.1 starts from the case of an attacker who obtains a proof and replays it at the same endpoint. htm and htu bind a proof to a specific method and URI. A POST proof sent to the token endpoint can’t be reused on a GET to a resource server, because it fails checks 8 and 9.
On the other hand, htm and htu can’t tell apart two requests with the same method and URI. Since htu is compared without the query, requests to the same path that differ only in their query string look like the same target at this stage. Time limits, jti and the nonce exist to narrow this gap.
iat and jti: accepting proofs only within a window
The first requirement in Section 11.1 concerns time. Servers MUST only accept proofs for a limited time after they were created. A parenthetical adds “preferably only for a relatively brief period on the order of seconds or minutes”, but the RFC doesn’t set a specific number. Servers MAY accept an iat slightly in the future to allow for clock offset.
Replay inside that window is handled with jti. Section 11.1 explains that, in the context of the target URI, servers can store the jti of each proof for the window in which it would be accepted, and decline a value they’ve already seen. This is described as something a server can do, not as a MUST. The same section acknowledges that a strictly enforced single-use check gives very strong protection against replay, but may not be feasible, for example when several servers behind the same endpoint don’t share state.
The section also advises that a server tracking jti values should reject unnecessarily large ones or store only a hash. That sentence uses a lowercase “should”, so it isn’t a normative keyword.
Put together, the time window is the baseline the RFC requires, and storing jti is the tool a server chooses when it also wants to stop replay inside that window. Without a jti store, the length of the window becomes the upper bound on how long a replay is possible. That last point is an inference from Section 11.1, not a sentence in the RFC.
nonce: the server decides the time
iat is chosen by the client. Section 11.2 points out that an attacker in control of the client can pre-generate proofs by setting iat to a future time. A server-supplied nonce prevents pre-generated proofs from being used beyond the current nonce’s lifetime. Because the server decides when to change the nonce, a proof pre-generated for the current nonce stops being usable once that nonce’s lifetime ends. Until then it can still be used, so replay within that lifetime still has to be handled with jti.
The flow uses different status codes depending on the kind of server.
- Authorization server (Section 8). If a nonce is required and the proof doesn’t include one, it responds with HTTP 400, a
use_dpop_nonceerror and aDPoP-Nonceheader. The client then retries with that value in thenonceclaim. - Resource server (Section 9). It responds with HTTP 401,
WWW-Authenticate: DPoP error="use_dpop_nonce"and aDPoP-Nonceheader.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce"
DPoP-Nonce: <value chosen by the server>
The main rules for nonces:
- Nonce values MUST be unpredictable. To the client they are opaque (Section 8).
- If the
noncein a proof doesn’t exactly match a nonce the authorization server recently supplied, the authorization server MUST reject the request (Section 8). - A server can supply a new nonce in the
DPoP-Nonceheader of a later response, and the client MUST use it from the next request on. Responses carryingDPoP-NonceSHOULD be uncacheable (Section 8.2). - Once a nonce has been supplied to a client, the server MUST NOT accept proofs without a
nonceclaim (Section 11.3). Without this rule, an attacker could bypass the nonce check simply by leaving it out. - A nonce should only be used with the server that issued it (Section 9).
A nonce isn’t a single-use value. Section 11.1 says it’s acceptable for clients to use the same nonce multiple times, and that as long as jti is tracked and duplicates are rejected for the lifetime of the nonce, there is no additional risk of token replay. In other words, the nonce cuts off a proof’s validity by the server’s clock, while duplicates within that period are still the job of jti. Section 11.1 also notes that limiting proof lifetime with nonces that encode server time gives the same effect regardless of clock skew between client and server.
For deployments that don’t use nonces, Section 11.2 says they SHOULD NOT issue long-lived DPoP-constrained access tokens and should prefer short-lived access tokens plus refresh tokens. Because of ath, a pre-generated proof can only be used within the lifetime of its access token anyway, so the shorter that lifetime, the shorter the exposure.
What each mechanism covers
| Mechanism | Strength in RFC 9449 | What it stops | What remains open |
|---|---|---|---|
Signature + jwk |
Verification MUST (4.3) | Proofs from anyone without the key | Proves key possession only, with no link to the request |
htm, htu |
Match checks MUST (4.3) | Reuse with a different method or URI | Replay to the same method and URI, query differences |
iat window |
Accept only for limited time MUST (11.1) | Replay of old proofs | Replay inside the window, proofs pre-generated with a future iat |
jti store |
Described as possible (“can”) (11.1) | Replay of the same proof inside the window | Multiple servers that don’t share state |
| Server nonce | Supplying is optional; checking after supplying is MUST (8, 11.3) | Use of pre-generated proofs beyond the current nonce’s lifetime; proof lifetime limits no longer depend on client and server clocks agreeing | Pre-generated proofs and replay within the nonce’s lifetime aren’t stopped; that still needs jti |
ath |
Required with an access token (4.2, 4.3) | Use with a different access token | Doesn’t prevent replay with the same access token; the time window and jti checks still apply |
None of these mechanisms help once an attacker’s code is running inside the client. Section 11.4 explains that DPoP’s guarantees don’t hold in that case. Making the key non-exportable stops the key itself from being stolen, but it doesn’t stop the attacker from creating and using proofs while the client is online.
What to check
- Confirm the server doesn’t stop at the signature and actually performs all twelve checks in Section 4.3.
- Check that
algis compared against a server-side allow list rather than trusted from the proof, and thatnoneand symmetric algorithms are rejected. - Make sure query and fragment are stripped from
htu, and RFC 3986 normalization is applied, before comparison. - Keep the accepted
iatrange narrow, on the order of seconds to minutes, with future tolerance no wider than clock offset requires. - If you keep a
jtistore, check that entries are scoped to the target URI and time window, and that every server behind the endpoint sees the same store. - Check that a proof without
nonceis rejected when the server has provided a nonce to that client. - If you don’t use nonces, check that DPoP-constrained access tokens are short-lived.
Protection at the authorization code stage is covered in OAuth 2.0 PKCE. PKCE protects the moment a code is exchanged for a token. DPoP binds the issued token to a key.
Reference
Checked on 2026-10-28.
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP): Abstract, Sections 1, 2, 3, 4.1, 4.2, 4.3, 8, 8.2, 9, 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7