Skip to content
TDACorpIDENTITYSign in

DPoP

What DPoP is, which clients must use it, how proofs are built and retried, and how to keep the key.

On this page

DPoP (RFC 9449) makes a stolen token useless on its own. Your application holds a private key. Identity binds the access token and the refresh token to the thumbprint of that key (cnf.jkt in the access token), and every use of them must come with a short signed proof made by the same key. The SDK builds and sends proofs for you; this page says when they are required and what you have to look after.

When DPoP is required#

There are two rules, and the second one is the one that cannot be configured away.

RuleWhat it means
The client is registered with require_dpopIdentity then demands a valid proof on the pushed authorization request, the token endpoint, the revocation endpoint and the introspection endpoint. The default at registration is on for web, spa and native clients, and for any other client that holds authorization_code or refresh_token. An explicit setting at registration wins, except for a public client, which always requires it. So does a client identified by a client ID metadata document, which exists only while the oauth_cimd flag is on (it defaults OFF).
Only a first-party web client may hold an unbound tokenAny other client that does not prove a key at the token endpoint is refused with invalid_client. This holds whatever require_dpop says, so a service client and a third-party client are always bound to a key. A service client registered with require_dpop off therefore cannot get tokens.

Note

A client that requires DPoP cannot sign in without a DPoP key. Pass dpop to createRelyingParty, or the pushed authorization request is refused.

When a client requires DPoP, the proof on the pushed authorization request also binds the authorization code to the key (RFC 9449 section 10): the token request must be signed by the same key, or the code is refused with invalid_grant. A refresh token is bound the same way, and a refresh with another key is refused with invalid_dpop_proof.

What a proof is#

A proof is a short JWT, signed with your key, sent in the DPoP request header.

PartValue
Header typdpop+jwt
Header algES256 (the SDK default) or EdDSA. Discovery lists them in dpop_signing_alg_values_supported.
Header jwkThe public key. Never the private members.
jtiA fresh value for every proof. Identity refuses a jti it has seen within its replay window, at most five minutes.
htmThe HTTP method of the request.
htuThe request address without query or fragment.
iatNow. Identity accepts a window of at most 60 seconds either side.
nonceThe latest DPoP-Nonce identity gave you. See below.
athThe base64url SHA-256 of the access token. Only when you present an access token.

The ath rule works in both directions. A proof sent to /par, /token, /revoke or /introspect carries no ath, and identity refuses one that does. A proof sent next to an access token, to a resource server or to identity's own /userinfo, must carry it.

The nonce challenge#

Identity requires a server-issued nonce in the proof. A proof without a current nonce is not an error to give up on:

  1. Your first request carries no nonce.
  2. Identity answers 400 with {"error": "use_dpop_nonce"} and a DPoP-Nonce header.
  3. You sign a new proof that includes that nonce and send the request again.

Nonces rotate every five minutes, and the previous one is still accepted, so a nonce stays usable for up to ten minutes after it was issued. The SDK does all of this: it remembers the latest nonce in memory, retries exactly once, and rebuilds the whole request on the retry, including a fresh client assertion, because identity has already recorded the assertion of the refused request. A second use_dpop_nonce is returned to your code as an ordinary error. Endpoints that take an access token, such as /userinfo, answer a missing nonce with 401 and the same header.

Keep the key#

The key must outlive your process. Refresh tokens are bound to its thumbprint, so an application that makes a new key at every start loses every refresh token at the next restart.

import { dpopThumbprint, exportDpopKeyPair, generateDpopKeyPair, importDpopKeyPair } from '@tdacorp/identity-sdk/rp'// Run once. Store the returned string in your secret manager: it is a private key.export async function createStoredDpopKey(): Promise<string> {  const pair = await generateDpopKeyPair('ES256') // or 'EdDSA'  return JSON.stringify(await exportDpopKeyPair(pair))}// Run at start-up. The thumbprint is the same after every restart, so refresh tokens bound to it keep working.export async function loadStoredDpopKey(stored: string) {  const pair = await importDpopKeyPair(JSON.parse(stored))  return { pair, thumbprint: await dpopThumbprint(pair) }}
  • Generate the key once, store the exported private JWK in your secret manager, and load it at start-up with importDpopKeyPair. The thumbprint is the same after the round trip.
  • One key for the whole application is the simplest arrangement. A key per user session, stored with that session's refresh token, limits what one leaked key can do.
  • Treat the exported JWK as a secret. It is a private key.

Sending a proof to a resource server#

rp.dpopProof({ method, url, accessToken }) builds the proof with ath, and serviceClient.headersFor(token, { method, url }) builds both headers for a service client. Send Authorization: DPoP <token> and DPoP: <proof>. The resource server side is covered in Protect your API.

Reference#

Verified against commit 936c0b4