Skip to content
TDACorpIDENTITYSign in

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#

HeaderContent
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#

TokenScheme usedOutcome
cnf.jkt presentDPoPGo on to verify the proof.
cnf.jkt presentBearerRefuse. A bound token must use the DPoP scheme.
No cnfDPoPRefuse. There is no key to prove.
No cnfBearerAccept, if you allow unbound tokens. A strict API requires a cnf on every token and refuses these.
cnf with only a certificate thumbprintanyRefuse 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.

  1. A DPoP header is present and not larger than 4096 bytes.
  2. It is a compact JWS with header typ: dpop+jwt.
  3. alg is EdDSA or ES256.
  4. The header carries jwk, a public key with no private members (d, k, p, q, dp, dq, qi).
  5. The signature verifies under that jwk.
  6. htm equals the request's HTTP method.
  7. htu equals your own address for this request, without query or fragment. Build it from the public origin you configure plus the request path. Never from the Host header or a forwarded header, which a caller can set.
  8. iat is within 60 seconds of now, either side.
  9. jti is present, not longer than 255 characters, and has not been seen before.
  10. ath equals the base64url SHA-256 of the access token as sent. This ties the proof to this token.
  11. The RFC 7638 thumbprint of jwk equals the token's cnf.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 htu against the Host header. Use your configured public origin.
  • Check ath and jkt both. One ties the proof to the token, the other to the key.
  • Refuse a proof signed with none or an algorithm outside your list. The SDK's proof check accepts only EdDSA and ES256.
  • Do not log proofs. They are valid for a short time and a replayed one is a stolen request.

Verified against commit 5d85077