---
title: "What DPoP protects you from, and what it doesn't — FoxAuth"
description: "A leaked access token is a password for whoever finds it. DPoP binds it to a key the client keeps: what that buys, what it costs, and when I would not bother."
canonical: "https://foxauth.dev/blog/what-dpop-protects-you-from/"
lastmod: "2026-09-18T11:34:34+03:00"
---
Blog
What DPoP protects you from, and what it doesn't
A leaked access token is a password for whoever finds it. DPoP binds it to a key the client keeps: what that buys, what it costs, and when I would not bother.
By FoxAuth · 2026-09-18

The token turned up in a crash report.

A mobile client integrating with an API I ran used an HTTP library that, on an unhandled exception,
serialised the failing request and shipped it to a third-party crash service. Headers included. So
somewhere in that vendor’s dashboard sat Authorization: Bearer eyJhbGci..., valid for another
fourteen minutes and readable by everyone on their support rota. Nobody did anything with it, as far
as I know. Nothing in the protocol would have stopped them if they had.

That is not a bug in anyone’s implementation. RFC 6750 says it in its definition of the term: any
party in possession of a bearer token can use it. The resource server checks the signature, the expiry, the
audience and the scope, and never asks the one question that mattered that afternoon, which is
whether the caller is the party the token was issued to. It cannot ask, because there is nothing in
the request to ask about.

DPoP, RFC 9449, puts something in the request to ask about. I want to be precise about what that
does and does not buy, because I have watched it get sold as “stops token theft”, and it does
something narrower and more useful than that.

What the client signs

The client generates a key pair before it asks for a token and keeps the private key. Every request
that presents the token also presents a small signed JWT, the proof, whose header carries the public
key and whose payload describes the request it belongs to. Decoded, a proof for a userinfo call
looks like this:

{

"typ": "dpop+jwt",

"alg": "ES256",

"jwk": { "kty": "EC", "crv": "P-256", "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA" }

}

{

"jti": "e1j3V_bKic8-LAEB",

"htm": "GET",

"htu": "https://auth.example.com/userinfo",

"iat": 1758196200,

"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",

"nonce": "eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o"

}

Each claim is there because a specific replay goes through the hole it closes, and the diagram
below is the map of which claim closes which. Read the arrows: every one of them leaves the proof
and lands on something the server can check independently of the client.

The accent arrow is the one the whole scheme hangs on. The access token carries a confirmation claim,
cnf.jkt, which is the RFC 7638 thumbprint of the public key that signed the proof. A resource
server that has never spoken to the client can still recover which key the token belongs to, from
the token alone, and then check that this proof was signed by that key. ath stops a proof being
lifted from one token and attached to another that happens to be bound to the same key. htm and
htu stop a proof made for one endpoint being replayed against a different one. jti lets the
server notice the same proof twice. nonce and iat are both about freshness, and they get their
own section, because they are where most of the operational pain lives.

Here is the whole thing on the wire, starting with the code exchange. The only visible additions to
a plain PKCE exchange are the DPoP header on the request and the token_type on the response:

POST /token HTTP/1.1

Host: auth.example.com

Content-Type: application/x-www-form-urlencoded

DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi...

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA

&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb

&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=spa-7f3a

HTTP/1.1 200 OK

Content-Type: application/json

Cache-Control: no-store

{

"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ...",

"token_type": "DPoP",

"expires_in": 900,

"scope": "openid orders:read",

"refresh_token": "Ql3oQ4fq8NWY0m9tHqqvqA1H7ArTn0O1x1PbUdECfxa"

}

token_type: DPoP is the server telling the client which scheme to use in Authorization from
now on. The client sends Authorization: DPoP <token> rather than Bearer, and a server that
receives a DPoP-bound token under the Bearer scheme should refuse it, because a client that does
not know it holds a bound token is a client that will also fail to send the proof. If the access
token is a JWT, the binding is in its payload; if it is opaque, the introspection response carries
the same cnf member.

{

"iss": "https://auth.example.com",

"sub": "248289761001",

"aud": "https://api.example.com",

"client_id": "spa-7f3a",

"exp": 1758197100,

"scope": "openid orders:read",

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

}

The sequence is then two steps at the authorization server and one verification block at the
resource server, and the verification block is the part worth staring at.

Now run the crash report through it. The vendor’s support engineer has the access token. They do
not have K, so they cannot sign a proof, so step 4 fails at the second line and the token is worth
nothing on its own. Suppose instead they captured a whole request, token and proof together. That
proof is good for one method, one URL, one token, and either five minutes of iat or one nonce, and
if the resource server keeps a replay store it is good exactly once. The attacker has gone from
holding a fourteen-minute password to holding a single request they may or may not be able to
repeat.

The part that gets oversold

That is a real improvement, and it is also the whole of the improvement. DPoP protects against a
token that leaks after it is issued and away from the client: the crash report, the proxy log, the
Referer header, the browser extension reading storage, the misconfigured cache. In every one of
those the attacker ends up with the token and not the key.

It does nothing against an attacker running code where the key lives. Cross-site scripting in a
single-page app that keeps the key in IndexedDB does not need to exfiltrate anything. It signs
proofs with the real key and calls the API as the real user, from the real page, and every check in
step 4 passes because they should. Binding a token to a key the attacker can also use protects
nothing. I have seen “we use DPoP” offered as the answer to “what happens if the frontend is
compromised” in more than one design review, and it is not an answer to that question at all. It
stops the replay of a stolen token from somewhere else. It does not stop the compromise of the place
that holds the key.

Nor does it stand in for the boring controls. A DPoP-bound token with a four-hour lifetime and no
audience is still a four-hour, any-audience token in the hands of whoever compromises the client.
Short lifetimes, audience restriction and refresh-token rotation are what limit that blast radius,
and DPoP composes with them rather than replacing any of them.

The nonce, and why the real enemy is clocks

Left to itself, the proof’s freshness rests on iat, which means it rests on the client’s clock
agreeing with the server’s. Our server accepts an iat within 300 seconds of its own time, and the
RFC deliberately leaves the window to the server. A phone whose clock is six minutes out fails every
request with invalid_dpop_proof, and nothing about that error says “your clock is wrong”. It says
the proof is bad. Somebody will spend an afternoon on it, and I have been that somebody.

The server nonce, RFC 9449 §8 and §9, replaces the client’s clock with the server’s. The server hands out
an opaque value, the client puts it in the next proof as nonce, and the server accepts the proof
if the nonce is one it recently issued. Freshness now comes from the nonce and iat becomes
advisory. The price is that on first contact the client has no nonce, so the first request fails by
design, and the failure has to carry the fix:

HTTP/1.1 401 Unauthorized

WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="nonce is required in the DPoP proof", algs="ES256 PS256"

DPoP-Nonce: eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o

The client reads DPoP-Nonce, signs a new proof with it, and retries. Every subsequent response
may carry a fresh DPoP-Nonce, which the client swaps in for the next request, so a busy client
never sees the 401 again and an idle one sees it once more after a few minutes. That is the round
trip the diagram shows, and it is the one that a client library has to implement as a loop rather
than as an error.

Two things in that exchange cost us real time, and neither is cryptography. The first is the
status code. RFC 9449 wants the authorization server to report a bad proof as a 400 with the error
in the body, the way every other token endpoint error is reported, and a resource server to report
it as a 401 with the error in WWW-Authenticate, the way RFC 6750 does. Same error class, two
statuses, decided by which role the endpoint is playing. We had both of our protected resources
correcting the status themselves in a catch block before we moved the decision into the shared error
handler, keyed on the route, so a third resource could not be added without it. The second is CORS.
A browser cannot read WWW-Authenticate or DPoP-Nonce from a cross-origin response unless the
server lists them in Access-Control-Expose-Headers, and without them the single-page app sees an
opaque 401 and can never perform the retry. The spec mentions this in passing. It is the difference
between DPoP working in a browser and not.

There is a design question hiding behind the nonce that I think is more interesting than the
spec makes it look: how does the server know which nonces it recently issued? The obvious answer is
to store them, and it is a bad answer, because a nonce is issued on every response and now every API
call writes to a store before it can return. We derive them instead. The nonce for a given minute is
an HKDF of a server secret with the minute number as the salt, and a proof is accepted if its nonce
matches any of the five minutes around now:

const STEP = 60;

function nonceFor(secret: Uint8Array, step: number): string {

const salt = new Uint8Array(8);

new DataView(salt.buffer).setBigUint64(0, BigInt(step));

return base64url(hkdfSync('sha256', secret, salt, '', 32));

}

export function nextNonce(secret: Uint8Array): string {

return nonceFor(secret, Math.floor(Date.now() / 1000 / STEP) + 1);

}

export function acceptsNonce(secret: Uint8Array, nonce: string): boolean {

const now = Math.floor(Date.now() / 1000 / STEP);

let mismatch = 0;

for (const offset of [-2, -1, 0, 1, 2]) {

mismatch |= constantTimeCompare(nonceFor(secret, now + offset), nonce);

}

return mismatch === 0;

}

Three choices in there are deliberate. The nonce handed out is next minute’s rather than this one’s,
so a client that receives it near the end of a minute still has three or four minutes before it ages
out of the window. The comparison runs against all five candidates and folds the results together
rather than returning on the first match, so the time it takes says nothing about which minute
matched. And nothing is stored, which means any replica holding the same secret accepts any other
replica’s nonce, and a restart forgets nothing because there was nothing to forget. What you give
up is revocation: a derived nonce cannot be withdrawn early, and it is not per-client. That is fine,
because a nonce is a freshness token, not a one-time token. The one-time property comes from jti,
and conflating the two is how people end up believing a nonce gives them replay protection. It does
not.

Replay detection is a database on your hot path

jti uniqueness means remembering every proof identifier you have accepted, per client, for as
long as a proof could still be valid, which with a 300-second window is 300 seconds. That is a write
on every authenticated API call and a read before it. For a resource server that is an
autoscaled function with no state of its own, it is a new dependency that did not exist before
DPoP, and the first time someone notices it is usually when the replay store is the thing paging
them.

The temptation is to switch it off, and here I want to be candid about a decision we made. Our
server has a switch that disables replay detection. It is boot configuration, and it is deliberately
absent from the administrative console and from the management API: it exists for a conformance
suite that replays one proof on purpose and for a person repeating a request while diagnosing
something, and neither of those is administration. A switch that removes a security property while
discovery goes on advertising the feature is exactly the kind of thing that gets flipped at two in
the morning during an incident and never flipped back, and the console is where two in the morning
happens. So it is not there.

What you actually lose without it is narrower than “DPoP stops working”. An attacker who captured a
complete request, token and proof, can replay that identical request, same method and URL, until
the proof’s nonce or iat ages out. Five minutes of replaying one GET /orders is a real leak and
not a catastrophe; five minutes of replaying one POST /transfers is a different conversation. If
your API is idempotent and read-heavy you may reasonably decide the store is not worth it. I would
not make that decision by default, and I would not make it reversible from a web page.

Where DPoP does its best work is not the access token

The spec has a clause that is easy to skim past. For a public client, one that cannot authenticate
itself at the token endpoint, the refresh token is bound to the key as well. For a confidential
client it is not, because a confidential client’s refresh token is already bound to the client
credentials the server checks on every use. Our implementation is a single condition: when the
client’s authentication method is none, the refresh token inherits the access token’s jkt.

I think that clause is the strongest argument for DPoP in a browser, stronger than anything about
the access token. In a single-page app the access token lives for fifteen minutes and the refresh
token lives for days, held in the one place an attacker with a leaked backup or a shared machine is
most likely to find it. Before DPoP a stolen refresh token from a browser was the keys to the
account for as long as rotation let it live. With DPoP and a non-extractable WebCrypto key, a stolen
refresh token is a string that cannot be exchanged, because the exchange itself requires a proof
signed by a key that did not leave the origin. Rotation still matters, but it is now catching an
attacker who has already been stopped rather than one who is already inside.

The same idea reaches back one step further with dpop_jkt, an authorization request parameter
that binds the authorization code to the key before the code is even issued, so a code intercepted
between the redirect and the exchange cannot be redeemed with a different key. PKCE already covers
most of that ground, and our server fills dpop_jkt in automatically when a proof arrives with a
pushed authorization request, so in practice it costs nothing. I would not turn DPoP on for it. I
would not turn it off either.

Where I would use it, and where I would not

The cost is three things, and none of them is the signature. The client needs a key, a place to keep
it and a proof on every request, which in a browser means a non-extractable CryptoKey in IndexedDB
and in a native app means the platform keystore, and an extractable key quietly gives back most of
what you paid for. The client needs to treat use_dpop_nonce as a retry, not a failure, and the
server needs to expose two headers to cross-origin callers or the retry can never happen. And
somebody has to own a replay store with a five-minute expiry, or own the decision not to.

Against that, DPoP earns its place when tokens travel through infrastructure you do not control, when
a public client holds a long-lived refresh token, or when the blast radius of one leaked token is
large enough that “fourteen minutes for anyone who finds it” is not a sentence you can say out loud
in a review. For a first-party, server-side confidential client talking to its own API over TLS with
five-minute tokens and a tight audience, I would not bother; mTLS-bound tokens or plain short-lived
bearer tokens buy the same thing for less client code.

The question I do not have an answer to is what browsers do next. Device Bound Session Credentials,
Chrome’s proposal and now an IETF draft, is an attempt at the same property for cookies, with the key held by the browser
rather than the page, which is precisely the boundary DPoP cannot cross. If that lands and grows an
API-token story, a lot of client-side DPoP code becomes something the platform does for you. If it
stays a cookie feature, DPoP remains the only sender-constraint a browser client can use without a
client certificate. I would build for the second and be pleased by the first.

If you want to see the shape of it in a running server, FoxAuth switches DPoP on server-wide and
then per client through the dpop_bound_access_tokens client attribute, the nonce requirement is a
setting, and the replay switch is the one described above, absent from the console on purpose. The
settings reference lists the options, the
endpoint reference shows which endpoints accept a proof, and the
threat model records what each check is there to stop.

All articlesTry it yourself
