Blog
What DPoP protects you from, and what it doesn't
A leaked access token works for anyone who finds it. DPoP ties it to a key the client keeps. What that stops, what it costs, and when I would skip it.
By FoxAuth ·
The token turned up in a crash report.
I ran an API, and a mobile app (the client, in OAuth terms) was being connected to it. When the app
hit an error it did not handle, its HTTP library packed up the failed request and sent it to an
outside crash-reporting service. The headers went too. So the vendor’s dashboard now showed
Authorization: Bearer eyJhbGci.... That token was still valid for fourteen minutes. Anyone on the
vendor’s support rota could read it. As far as I know, nobody used it. But if someone had, nothing in
the protocol would have stopped them.
Nobody’s code had a bug. RFC 6750 defines a bearer token this way: anyone who holds it can use it. The resource server is the API that accepts the token. It checks the token’s signature, its expiry, its audience (which API it was issued for) and its scope (what it allows). But it never asks the one question that mattered that afternoon: is the caller the same party the token was issued to? It cannot ask. The request contains nothing that would answer it.
DPoP (RFC 9449) adds something to the request that answers that question. I have seen DPoP sold as “stops token theft”. What it does is narrower, and more useful.
What the client signs on every request, and why a stolen token is useless without the key
Before it asks for a token, the client generates a key pair: a public key and a private key. It keeps the private key to itself. From then on, every request that carries the token also carries a small signed JWT, called the proof. The proof’s header contains the public key. Its payload (the body of the JWT) describes the request the proof belongs to. Here is a proof for a call to the userinfo endpoint, decoded:
{ "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"}The fields of a JWT are called claims. Each claim in the proof closes one hole. Without it, a particular kind of replay would get through. A replay is an attacker sending something they captured a second time. The diagram below shows which claim closes which hole. Follow the arrows: each one starts at the proof and ends at something the server can check on its own, without relying on the client.
The highlighted arrow is the one the whole scheme depends on. It is the sender-constraint: the token
is tied to a key, and whoever sends the token must prove they hold that key. The access token carries
a confirmation claim, cnf.jkt (cnf is short for “confirmation”). Its value is the thumbprint of the
public key that signed the proof. A thumbprint is a short hash of a key, computed the way RFC 7638
describes. The resource server may never have talked to the client. Even so,
the token alone tells it which key the token belongs to. Then it checks that this proof was signed
with that key.
The other claims each close one kind of replay. ath is a hash of the access token itself. It stops someone from taking a
proof made for one token and attaching it to another token that is bound to the same key. htm and
htu are the request’s method and URL. They stop a proof made for one endpoint from being replayed
against a different one. jti is a unique identifier for each proof. It lets the server notice when
the same proof arrives twice. nonce and iat are both about freshness: how recently the proof was
made. They get their own section below, because that is where most of the trouble of running DPoP
lives.
Here is what DPoP looks like in HTTP traffic. It starts with the code exchange, where the client
trades its authorization code for tokens. Compared with an ordinary exchange that uses PKCE (the
code_verifier that proves the same client started the request), only two things are new: the DPoP header in the request and the token_type field in the response:
POST /token HTTP/1.1Host: auth.example.comContent-Type: application/x-www-form-urlencodedDPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi...
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=spa-7f3aHTTP/1.1 200 OKContent-Type: application/jsonCache-Control: no-store
{ "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ...", "token_type": "DPoP", "expires_in": 900, "scope": "openid orders:read", "refresh_token": "Ql3oQ4fq8NWY0m9tHqqvqA1H7ArTn0O1x1PbUdECfxa"}token_type: DPoP tells the client which scheme to write in the Authorization header from now
on. The client sends Authorization: DPoP <token> instead of Bearer. If a server receives a
DPoP-bound token under the Bearer scheme, it should refuse it, because a client that writes
Bearer does not know its token is bound to a key. So that client will not send a proof either.
Where the binding is kept depends on the kind of token. An opaque token is a random string with
nothing inside it; only the authorization server knows what it stands for. To check one, the
resource server asks the authorization server about it (this request is called introspection). The
binding comes back in the answer, as a cnf field. A JWT, on the other hand, carries its own
contents. If the access token is a JWT, as this one is, the same cnf field sits in its payload:
{ "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" }}So the whole sequence is two steps at the authorization server, then one block of checks at the resource server. That block of checks, step 4, is the part worth reading closely.
Now run the crash report through this sequence. The vendor’s support engineer has the access token. They do not have the private key K, so they cannot sign a proof with it. A proof signed with any other key fails step 4, at the thumbprint check. On its own, the token is worth nothing.
Suppose instead they captured a whole request: the token and its proof together. That proof works for
one method, one URL and one token only. It stays fresh either for five minutes, judged by iat, or
for as long as its one nonce is accepted. And if the resource server keeps a replay store (a record
of the jti values it has already accepted), the proof works exactly once. So the attacker no longer
holds a password that works for fourteen minutes. They hold a single request that they may or may
not be able to send again.
What DPoP does not stop: code running where the key is kept
Shrinking a leaked token to a single request is a real improvement. It is also the only improvement.
DPoP protects you when the token leaks after it was issued, somewhere outside the client: a crash
report, a proxy log, the Referer header, a browser extension that reads storage, a misconfigured
cache. In each of these cases the attacker ends up with the token but not the key.
DPoP does nothing against an attacker whose code runs where the key is kept. Take cross-site scripting (XSS): someone else’s script running inside your page. Say a single-page app (a web app that runs entirely in the browser) keeps its key in IndexedDB, the database built into the browser. The attacker’s script does not need to steal anything and send it out. It signs proofs with the real key. It calls the API as the real user, from the real page. Every check in step 4 passes, as it should. Binding a token to a key that the attacker can also use protects nothing.
In more than one design review I have heard this exchange: “What happens if the frontend is compromised?” “We use DPoP.” That reply does not answer the question at all. DPoP stops a stolen token from being replayed somewhere else. It does not help when the place that holds the key is itself compromised.
DPoP does not replace the boring basics either. Take a DPoP-bound token that lives four hours and has no audience restriction. Whoever compromises the client gets a token that works for four hours, against any API. What limits that damage is short token lifetimes, audience restriction and refresh-token rotation. A refresh token is the longer-lived token the client uses to get new access tokens; rotation means each use of it returns a new one. DPoP works alongside these basics.
Why the server hands out a nonce: the client’s clock may be wrong
Without a nonce, the only sign that a proof is fresh is iat, the time it was signed. So freshness
depends on the client’s clock agreeing with the server’s. Our server accepts an iat up to 300
seconds away from its own time. The RFC deliberately leaves the size of that window to each server.
Now take a phone whose clock is six minutes off. Every one of its requests fails with
invalid_dpop_proof. Nothing in that error says “your clock is wrong”. It only says the proof is bad.
Someone will lose an afternoon to it. I have been that someone.
A server nonce (RFC 9449, §8 and §9) takes the client’s clock out of the picture and uses the
server’s instead. The nonce is a value the server hands out. The client does not need to understand
what is inside it. It puts the value in the nonce field of its next proof. The server accepts
the proof if it issued that nonce recently. Freshness now comes from the nonce, and iat is only for
reference. The price is the first request. On first contact the client has no nonce yet, so its
first request fails, by design. That failure has to carry everything the client needs to fix it:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: DPoP error="use_dpop_nonce", error_description="nonce is required in the DPoP proof", algs="ES256 PS256"DPoP-Nonce: eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2oThe client reads the DPoP-Nonce header, signs a new proof with that nonce, and retries. Any later
response may carry a fresh DPoP-Nonce, and the client puts it into its next request. So a client
that keeps making requests never sees the 401 again. A client that sits idle for a few minutes sees
it once more. The diagram below shows this round trip. A client library has to build it as an
ordinary loop, not handle it as an error.
Two things in this exchange cost us real time, and neither was cryptography. The first was the
status code. Take the token endpoint, /token, where the authorization server issues tokens. There,
RFC 9449 wants a bad proof reported as a 400 with the error in the response body, like every other
error from that endpoint. A resource server reports the same problem as a 401 with the error in the
WWW-Authenticate header, the way RFC 6750 does. So one error has two status codes, and the right
one depends on the role the endpoint plays. Our server has two endpoints of its own that accept
access tokens (protected resources). At first, each of them fixed the status itself, in its own catch
block. Then we moved that decision into the shared error handler, which now picks the status by
route. Since then, a third protected resource cannot be added without getting the same rule.
The second was CORS: the rules a browser uses to decide what a script may read from a response sent
by another origin (a different scheme, host or port). The browser will not let the script read the
WWW-Authenticate or DPoP-Nonce header of such a response unless the server lists them in
Access-Control-Expose-Headers. Without them, the single-page app sees a 401 but cannot see what is
in it, so it can never retry. The spec mentions the exposed headers in passing. Yet they decide
whether DPoP works in a browser at all.
Behind the nonce there is a design question that I find more interesting than the spec makes it look: how does the server know which nonces it issued recently? The obvious answer is to store them. That is a bad answer. A nonce goes out with every response, so every API call would have to write to a store before it could return. So we do not store nonces; we compute them. The nonce for a given minute comes from HKDF, a standard function that derives a new value from a secret. Its inputs are a server secret and, as the salt (an extra input), the number of the minute. A proof is accepted if its nonce matches the nonce of any of the five minutes around the current one:
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 matched = 0; for (const offset of [-2, -1, 0, 1, 2]) { matched |= Number(constantTimeEqual(nonceFor(secret, now + offset), nonce)); } return matched === 1;}Three choices in that code are deliberate. First, the server hands out next minute’s nonce, not this
minute’s. A client that receives it near the end of a minute still has three or four minutes before
it drops out of the window. Second, acceptsNonce compares the nonce with all five candidates and
combines the results. It does not stop at the first match. So the time the check takes reveals
nothing about which minute matched. Third, nothing is stored. Any replica (any copy of the server)
that holds the same secret accepts a nonce issued by any other replica. And a restart forgets
nothing, because there was nothing to remember.
What you give up is revocation. A computed nonce cannot be withdrawn early, and it is not tied to any
one client. Neither limit matters, because a nonce only shows that a proof is fresh. It does not make the
proof single-use. Single use comes from jti. Mixing the two up is how people come to believe that
a nonce protects them from replay. It does not.
Catching replays means a database lookup on every request
To catch replays by jti, the server has to remember the identifier of every proof it has accepted,
per client. It has to remember each one for as long as a proof could still be accepted: five
minutes, or up to ten for a proof dated ahead of the server’s clock. That means a read and then a
write on every API call that carries a token. Say your resource server is an autoscaled function with
no state of its own. Then this store is a new dependency that did not exist before DPoP. Usually,
the first time anyone notices it is when the replay store is the thing setting off their alerts.
It is tempting to switch replay detection off, and our server has a switch that does it. The switch is set in the configuration the server reads at startup. On purpose, it is not in the admin console or in the management API. It exists for two cases. One is a conformance test suite (tests that check a server follows the standard) that replays one proof on purpose. The other is a person who repeats a request while tracking down a problem. Neither of those is administration.
A switch that removes a security protection while discovery (the document where the server lists what it supports) keeps advertising the feature is the kind someone flips at two in the morning during an incident and never flips back. And at two in the morning, people are working in the console. So the switch is not there.
Without replay detection you lose much less than “DPoP stops working”. Suppose an attacker captured a
complete request, token and proof. They can replay that exact request, with the same method and URL,
until the proof’s nonce or iat is too old. Replaying one GET /orders for five minutes is a real
leak, but not a catastrophe. Replaying one POST /transfers for five minutes is a different
conversation. Your API may be idempotent (repeating a request changes nothing) and mostly serve
reads. Then you may reasonably decide the replay store is not worth it. I would not make that the
default. And I would not let anyone switch it back and forth from a web page.
DPoP’s biggest win is protecting the refresh token, not the access token
The spec has a clause that is easy to skim past. Some clients are public: they cannot prove who they
are at the token endpoint. For a public client, the refresh token is bound to the key as well. For a
confidential client, one that can prove who it is, it is not. A confidential client’s refresh token
is already tied to the client’s credentials, and the server checks those every time the refresh
token is used. In our implementation this is a single condition: when the client’s authentication
method is none, the refresh token gets the same jkt as the access token.
I think this 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 fifteen minutes, but the refresh token lives for days. It is kept in browser storage, and browser storage is where an attacker is most likely to find it: in a leaked backup of the browser profile, or on a shared computer. Before DPoP, a refresh token stolen from a browser opened the account for as long as rotation let that token live.
Now add DPoP and a non-extractable WebCrypto key: a key the page can use but cannot copy out of the browser. A stolen refresh token is then a string that cannot be exchanged. The exchange itself needs a proof signed with the key, and the key never left the origin. Rotation still matters. But now it catches an attacker who has already been stopped, not one who is already inside.
The same idea works one step earlier, through dpop_jkt. This is a parameter of the authorization
request. It binds the authorization code to the key before the code is even issued. A code
intercepted between the redirect and the exchange then cannot be redeemed with a different key.
PKCE already protects against most of that. And our server fills in dpop_jkt by itself when a proof
arrives with a pushed authorization request (PAR, where the client first sends its authorization
parameters straight to the server). So in practice the binding costs nothing. It is not a reason to turn DPoP
on. It is not a reason to turn it off either.
When DPoP is worth its cost, and when it is not
DPoP costs three things, and the signature is not one of them. First, the client needs a key, a place
to keep it, and a proof on every request. In a browser the key should be a non-extractable
CryptoKey in IndexedDB; in a native app, the platform’s keystore. If the key can be extracted, you
silently lose most of what you paid for. Second, the client has to treat use_dpop_nonce as a reason
to retry, not as a failure. And the server has to expose two headers to cross-origin callers, or the
retry can never happen. Third, someone has to own a replay store whose entries expire after five
minutes, or own the decision not to have one.
In return, DPoP earns its place in three cases. Tokens pass through infrastructure you do not control. A public client holds a long-lived refresh token. Or one leaked token could do so much damage that “fourteen minutes for anyone who finds it” is not something you could say out loud in a review.
Now the opposite case: your own confidential client, running on a server, calling your own API over TLS, with five-minute tokens and a narrow audience. There I would not bother. Tokens bound through mTLS (TLS where the client also presents a certificate), or plain short-lived bearer tokens, give you the same thing with less client code.
What I cannot answer is what browsers will do next. Device Bound Session Credentials (DBSC) started as a Chrome proposal and is now a W3C working draft. It tries to give cookies the same property: binding to a key. There the key is held by the browser, not by the page. DPoP cannot cross that boundary between page and browser. If DBSC lands and grows a way to work with API tokens, much of today’s client-side DPoP code becomes something the platform does for you. If it stays a cookie-only feature, DPoP remains the only sender-constraint a browser client can use without a client certificate. I would build for the second outcome and be pleased by the first.
If you want to see how DPoP looks in a running server: in FoxAuth you switch DPoP on for the whole
server first, then for each client through the dpop_bound_access_tokens client attribute. Whether a
nonce is required is a setting. The replay switch is the one described above, kept out of 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.