DPoP verification for resource servers
The exact checks an API makes on a DPoP-bound access token and its per-request proof, and the refusals to send.
On this page
A DPoP-bound access token is useless to a thief on its own: every request must also carry a short signed proof made with the key the token is bound to. The client side is in DPoP. This page is for the API that receives the request.
What you receive#
| Header | Content |
|---|---|
Authorization: DPoP <access token> | A token whose cnf carries jkt, the thumbprint of the client's key. |
DPoP: <proof> | A JWT signed by that key, made for this one request. |
Every client except a first-party web client receives bound tokens, so a real API sees them often. An unbound token arrives as Authorization: Bearer <token> and has no cnf.
The decision on the scheme#
| Token | Scheme used | Outcome |
|---|---|---|
cnf.jkt present | DPoP | Go on to verify the proof. |
cnf.jkt present | Bearer | Refuse. A bound token must use the DPoP scheme. |
No cnf | DPoP | Refuse. There is no key to prove. |
No cnf | Bearer | Accept, if you allow unbound tokens. A strict API requires a cnf on every token and refuses these. |
cnf with only a certificate thumbprint | any | Refuse unless you verify certificate-bound tokens. The SDK does not. |
Verify the proof#
All of these, in this order. The SDK's verifyDpopProof does them, and createResourceServer calls it for you.
- A
DPoPheader is present and not larger than 4096 bytes. - It is a compact JWS with header
typ: dpop+jwt. algisEdDSAorES256.- The header carries
jwk, a public key with no private members (d,k,p,q,dp,dq,qi). - The signature verifies under that
jwk. htmequals the request's HTTP method.htuequals your own address for this request, without query or fragment. Build it from the public origin you configure plus the request path. Never from theHostheader or a forwarded header, which a caller can set.iatis within 60 seconds of now, either side.jtiis present, not longer than 255 characters, and has not been seen before.athequals the base64url SHA-256 of the access token as sent. This ties the proof to this token.- The RFC 7638 thumbprint of
jwkequals the token'scnf.jkt. This is what ties the proof to the token's key.
Step 9 is the one an implementation most often gets wrong. A proof may be used once. Record each jti for at least as long as a proof can be accepted (two minutes covers the 60-second window either side) and refuse a repeat.
Nonces#
Identity's own endpoints require a server-issued nonce in the proof, and an operator can tighten the proof's iat window there below 60 seconds. A resource server does not have to. The SDK's resource-server verifier does not demand one, so a client talking to your API need not have a nonce from you. If you add one, answer a proof without it with 401 and a DPoP-Nonce header, as RFC 9449 describes.
What to send when you refuse#
A refusal is 401 with a challenge in WWW-Authenticate that names the algorithms you accept, and a body that does not say which check failed.
HTTP/1.1 401 UnauthorizedWWW-Authenticate: DPoP algs="EdDSA ES256", error="invalid_dpop_proof"Content-Type: application/json{"error":"invalid_dpop_proof"}Use invalid_token when the access token itself failed, and invalid_dpop_proof when the proof did. When the token and proof are fine but the sign-in is too old or too weak, the SDK's requireAuthentication answers insufficient_user_authentication instead (Access token). A request with no credentials gets the challenge without an error. An API that also allows unbound tokens adds a Bearer challenge. Never echo the token, the proof or the reason.
The replay store#
The store holds the jti values you have seen. The SDK's in-memory store keeps 50,000 entries for two minutes and is per instance. That is correct for a single process and not for several: a proof captured at one instance could be replayed once at another inside its window. If your API runs on several instances, or on a platform where each request may reach a new one, implement the store over shared storage. Put a rate limit in front of the API either way, because a full store refuses new proofs rather than dropping a live one. See Protect your API.
Worked example#
import { ResourceServerError, createMemoryDpopReplayStore, createResourceServer } from '@tdacorp/identity-sdk'const resourceServer = createResourceServer({ issuer: 'https://identity.tdacorp.in', audience: 'https://api.example.com', publicOrigin: 'https://api.example.com', replay: createMemoryDpopReplayStore(),})export async function GET(request: Request): Promise<Response> { try { const token = await resourceServer.verifyRequest(request) return Response.json({ sub: token.subject }) } catch (error) { if (error instanceof ResourceServerError) return error.response() throw error }}The SDK is the package @tdacorp/identity-sdk on the public npm registry; see SDK.
Security notes#
- Do not accept a bound token as a bearer token. That removes the whole protection.
- Do not verify
htuagainst theHostheader. Use your configured public origin. - Check
athandjktboth. One ties the proof to the token, the other to the key. - Refuse a proof signed with
noneor an algorithm outside your list. The SDK's proof check accepts onlyEdDSAandES256. - Do not log proofs. They are valid for a short time and a replayed one is a stolen request.