DPoP proofs
DPoP lets a client prove that it holds a private key when using an access token. The client signs a short JWT for each HTTP request. The server checks the signature, the method and URL, and the connection to the access token.
unjwt/dpop supplies those proof operations from RFC 9449. It is a separate import; core JWS, JWE and session imports do not include it.
#Create a proof
Keep both halves of the client's key pair. Pass a Web Request or an object with method and url:
import { createDPoPProof } from "unjwt/dpop";
import { generateJWK } from "unjwt/jwk";
const keyPair = await generateJWK("ES256"); // Keep this pair for the bound token's lifetime.
const target = { method: "POST", url: "https://auth.example.com/token" };
const proof = await createDPoPProof(target, { keyPair });
// Your HTTP code sends this value in the DPoP header.algorithm is optional for JWK pairs. The helper uses the private key's alg, or the public key's when the private key omits it. Without either, it infers a single choice from the key's curve when possible, such as P-256 → ES256. If several choices remain, supply algorithm. RSA keys without alg are one example. CryptoKeyPair inputs require an explicit algorithm.
DPoP requires asymmetric keys. Symmetric JWKs (kty: "oct"), secret CryptoKeys, HMAC algorithms and none are rejected. A supplied algorithm must agree with the key pair's algorithm metadata.
Each call generates a fresh UUID v4 for jti (the proof ID) and sets iat (creation time) to the current Unix second.
Both can be overridden, for example in tests:
const proof = await createDPoPProof(target, {
keyPair,
algorithm: "ES256",
jti: "550e8400-e29b-41d4-a716-446655440000",
iat: 1_700_000_000,
});When providing your own jti, use a UUID v4 or at least 96 random bits, and never reuse it for a new proof. Overrides are validated; an empty ID or invalid timestamp is rejected.
exp and nbf are optional Unix timestamps in seconds. Both are omitted by default:
const now = Math.floor(Date.now() / 1000);
const proof = await createDPoPProof(target, {
keyPair,
exp: now + 30, // Reject at or after this time.
nbf: now, // Reject before this time.
});When present, verification checks both using clockTolerance. The proof must also pass the iat / maxTokenAge check; setting a later exp does not extend that window. Invalid timestamp inputs are rejected.
keyPair accepts an asymmetric JWK pair or a CryptoKeyPair. The keys must correspond. A private CryptoKey may be non-extractable; the public one must be exportable so its public fields can go into the proof. Creation does not derive a public key or test the pair by verifying its own signature.
The helper sets typ: "dpop+jwt", includes the public key, and adds htm (method) and htu (URL). It preserves method case and removes the URL's query and fragment. No HTTP call occurs.
For a protected API call, also pass the exact access token:
const proof = await createDPoPProof(
{ method: "GET", url: "https://api.example.com/orders?page=2" },
{
keyPair,
accessToken: { token: accessToken },
nonce, // Include when this server requires a nonce.
},
);This adds ath, a SHA-256 digest of the token. A nonce is a value supplied by the server that the client copies into its next proof. Your HTTP layer reads nonce headers and handles retries.
#Verify a proof
Verification takes the proof string first, followed by the HTTP target and its options:
import { verifyDPoPProof } from "unjwt/dpop";
const { payload, thumbprint } = await verifyDPoPProof(
proof,
{ method: "POST", url: "https://auth.example.com/token" },
{ algorithms: ["ES256"], maxTokenAge: 60 },
);target accepts a Web Request or { method, url }. Use the externally visible URL when behind a trusted proxy.
The result contains the checked proof claims and the signing key's SHA-256 thumbprint: a stable identifier computed from its public fields. An authorization server can use that identifier when binding a token to the client key. The proof's public key must carry its members in canonical unpadded base64url, so one key always yields one thumbprint.
| Option | Meaning |
|---|---|
algorithms | Required list of accepted asymmetric signing algorithms. HMAC and none are rejected. |
maxTokenAge | Required positive number of seconds a proof may be old. Choose a brief window. |
clockTolerance | Non-negative seconds allowed for clock differences; defaults to 0. Applies to future and old proofs. |
currentDate | Clock used for verification; defaults to the current time. Useful for tests. |
nonce | Expected server nonce. When supplied, a missing or different nonce fails with ERR_DPOP_NONCE_INVALID. |
accessToken | { token, thumbprint }: the exact token and its trusted key binding, checked together. |
consumeProof | Optional callback for an atomic replay check; described below. |
URL comparison ignores query and fragment, normalizes host and scheme casing, default ports, dot segments and unreserved percent escapes in the path. An escaped slash stays distinct from a path separator. A URL with credentials or a percent escape in its host or port is rejected. The method comparison is case-sensitive.
Freshness always uses iat and the supplied age limit. A nonce does not replace this time check. Without an expected nonce, a nonce in the proof is only checked for valid syntax.
#Check the access-token binding
Both operations accept an accessToken object: creation needs { token }; verification needs { token, thumbprint }.
For a protected resource, first validate the access token using your application's token validator. Then supply the token and its authenticated cnf.jkt value:
const verified = await verifyDPoPProof(proof, request, {
algorithms: ["ES256"],
maxTokenAge: 60,
accessToken: {
token: accessToken,
thumbprint: trustedTokenClaims.cnf.jkt,
},
nonce: expectedNonce,
});This checks both connections: the proof names this exact token, and the proof's signing key matches the key bound to that token. A decoded but unverified cnf.jkt is not a trusted binding. Omitting accessToken performs proof verification without these token-binding checks, as needed at a token endpoint.
#Prevent replay
Signature and time checks alone do not detect reuse inside the accepted time window. Supply consumeProof to check and record a proof in your own store:
const maxTokenAge = 60;
const clockTolerance = 5;
await verifyDPoPProof(proof, request, {
algorithms: ["ES256"],
maxTokenAge,
clockTolerance,
accessToken: { token: accessToken, thumbprint: trustedTokenClaims.cnf.jkt },
consumeProof: ({ payload, thumbprint }) =>
replayStore.insertIfAbsent({
key: JSON.stringify([thumbprint, payload.jti]),
expiresAt: payload.iat + maxTokenAge + clockTolerance,
}),
});replayStore is your application's store. Its check and insert must be atomic across your servers. Return true only for a newly recorded proof; false rejects it. Keep the record through the final instant when the proof can be accepted, and bound stored key sizes or hash them. The callback runs only after all other checks pass and receives a copy of the result, so changing it does not change what verifyDPoPProof returns. A thrown error or any return value other than true also rejects the proof.
There is no built-in replay store. Your application owns HTTPS, checking that exactly one DPoP header was received, the Authorization: DPoP scheme, access-token validity and permissions, nonce issuance and HTTP errors. Verification does not grant access by itself.
#Errors
Import JWTError and isJWTError from unjwt/dpop. These are the same error types as the core modules. verifyDPoPProof throws only these codes:
| Code | Meaning | RFC 9449 error |
|---|---|---|
ERR_INVALID_OPTION | Invalid caller configuration: target, algorithms, time limits, expected nonce, accessToken or consumeProof. | None: the server configuration needs fixing. |
ERR_DPOP_INVALID | The proof's form, protected header, embedded key, algorithm (including one outside algorithms), signature, typ, crit or payload is rejected. | invalid_dpop_proof |
ERR_DPOP_CLAIM_INVALID | A claim check fails: a missing or malformed claim, exp / nbf, htm / htu, the iat age window, ath or the key binding, a malformed nonce. | invalid_dpop_proof |
ERR_DPOP_NONCE_INVALID | An expected nonce is missing from the proof or different. Checked after every other proof check passes. | use_dpop_nonce |
ERR_DPOP_REPLAY | The replay callback rejected the proof or failed. A thrown error is preserved as cause. | invalid_dpop_proof for a rejected proof; a thrown store failure is a server error. |
When a core JWS, JWK or JWT check rejects the proof, its JWTError is kept as cause, for example ERR_JWS_SIGNATURE_INVALID under ERR_DPOP_INVALID or ERR_JWT_EXPIRED under ERR_DPOP_CLAIM_INVALID. See Errors for the full code list.
import { isJWTError, verifyDPoPProof } from "unjwt/dpop";
try {
await verifyDPoPProof(proof, request, { algorithms: ["ES256"], maxTokenAge: 60, nonce });
} catch (error) {
if (isJWTError(error, "ERR_DPOP_NONCE_INVALID")) {
// Respond with error "use_dpop_nonce" and a fresh DPoP-Nonce header.
} else if (isJWTError(error, "ERR_INVALID_OPTION")) {
throw error;
} else {
// Respond with error "invalid_dpop_proof".
}
}