Skip to content

DPoP Validation

DPoP (Demonstrating Proof of Possession, RFC 9449) is a mechanism that binds access tokens to a client’s cryptographic key pair. A standard Bearer token can be used by anyone who possesses it. A DPoP-bound token can only be used by the client that holds the corresponding private key.

The client includes a DPoP proof — a signed JWT — with each request. The resource server validates:

  1. The proof is signed by the key bound to the access token
  2. The proof matches the current HTTP method and URL
  3. The proof has not been replayed

As a resource server using @authrim/server, your DPoP responsibilities are:

ResponsibilityHandled by SDKHandled by Application
Parse DPoP proof JWTYes
Verify proof signatureYes
Validate typ, alg, jwk headerYes
Validate htm, htu, iat claimsYes
Verify ath (access token hash)Yes
Verify JWK Thumbprint binding (cnf.jkt)Yes
Reject private key parameters in JWKYes
jti replay protectionYes
DPoP nonce managementYes
const dpopResult = await authrim.validateDPoP(proof, options);
ParameterTypeDescription
proofstringThe raw DPoP proof JWT from the DPoP request header
optionsDPoPValidationOptionsValidation parameters
interface DPoPValidationOptions {
method: string;
uri: string;
accessToken?: string;
expectedThumbprint?: string;
expectedNonce?: string;
maxAge?: number;
clockTolerance?: number;
}
OptionTypeDefaultDescription
methodstringHTTP method of the current request (e.g., 'GET', 'POST')
uristringFull request URI. The SDK normalizes it to scheme + host + path for htu comparison
accessTokenstringAccess token used to verify the proof ath claim
expectedThumbprintstringExpected JWK Thumbprint from the token’s cnf.jkt claim
expectedNoncestringServer-issued nonce that must be present in the proof
maxAgenumber60Maximum age of the proof in seconds (based on iat)
clockTolerancenumber60Clock skew tolerance in seconds

The current SDK accepts ES256, PS256, and EdDSA DPoP proof algorithms.

flowchart TD
    A["Receive DPoP Proof"] --> B["Parse JWT Header<br>(typ: dpop+jwt)"]
    B --> C["Check Algorithm<br>(reject none, check allowlist)"]
    C --> D["Extract Public Key<br>(reject private key params)"]
    D --> E["Verify Proof Signature"]
    E --> F["Validate htm<br>(matches HTTP method)"]
    F --> G["Validate htu<br>(matches request URL)"]
    G --> H["Validate iat<br>(within maxAge)"]
    H --> I{"ath present?"}
    I -->|Yes| J["Verify Access Token Hash"]
    I -->|No| K["Skip ath check"]
    J --> L{"expectedThumbprint?"}
    K --> L
    L -->|Yes| M["Verify JWK Thumbprint<br>(timing-safe)"]
    L -->|No| N["Skip binding check"]
    M --> O["Return DPoP Result"]
    N --> O

Here is the typical flow for validating a DPoP-bound request:

import { createAuthrimServer } from '@authrim/server';
const authrim = createAuthrimServer({
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
await authrim.init();
async function handleRequest(req) {
// 1. Extract tokens from headers
const authHeader = req.headers['authorization'];
const dpopProof = req.headers['dpop'];
if (!authHeader?.startsWith('DPoP ')) {
throw new Error('Missing DPoP authorization');
}
const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
// 2. Validate the access token
const tokenResult = await authrim.validateToken(accessToken);
if (tokenResult.error) {
throw new Error(tokenResult.error.message);
}
// 3. If token is DPoP-bound, validate the proof
if (tokenResult.data.tokenType === 'DPoP' && dpopProof) {
const dpopResult = await authrim.validateDPoP(dpopProof, {
method: req.method,
uri: `${req.protocol}://${req.hostname}${req.path}`,
accessToken,
expectedThumbprint: tokenResult.data.claims.cnf?.jkt,
});
if (!dpopResult.valid) {
throw new Error(dpopResult.errorMessage ?? 'Invalid DPoP proof');
}
}
return tokenResult.data;
}

The JWK Thumbprint is a SHA-256 hash of the public key’s canonical form. It binds the access token (via the cnf.jkt claim) to the DPoP proof’s signing key.

The SDK provides utility functions for thumbprint operations:

import {
calculateJwkThumbprint,
verifyJwkThumbprint,
} from '@authrim/server';
// Calculate a thumbprint from a JWK
const thumbprint = await calculateJwkThumbprint(publicJwk);
// Verify a thumbprint matches a JWK (timing-safe)
const isValid = await verifyJwkThumbprint(publicJwk, expectedThumbprint);

The thumbprint calculation follows RFC 7638:

  1. Extract the required members for the key type (kty, crv, x, y for EC; kty, e, n for RSA)
  2. Serialize as a JSON object with sorted keys and no whitespace
  3. Compute the SHA-256 hash
  4. Encode as Base64url

validateDPoP() returns validation status and the proof key thumbprint. It does not expose jti, so extract jti from the proof payload in your application code before writing to the replay cache.

// Example: DPoP jti replay protection with Redis
const DPOP_JTI_TTL = 60; // Matches maxAge
async function checkDPoPReplay(jti: string): Promise<boolean> {
const key = `dpop:jti:${jti}`;
// SET NX returns 'OK' only if the key did not exist
const result = await redis.set(key, '1', 'EX', DPOP_JTI_TTL, 'NX');
return result === 'OK'; // true = new jti, false = replay
}
// Usage in your request handler
async function handleProtectedRequest(req) {
const tokenResult = await authrim.validateToken(accessToken);
if (tokenResult.error) {
throw new Error(tokenResult.error.message);
}
const jti = extractJtiFromValidatedProof(dpopProof);
const dpopResult = await authrim.validateDPoP(dpopProof, { /* ... */ });
if (!dpopResult.valid) {
throw new Error(dpopResult.errorMessage ?? 'Invalid DPoP proof');
}
// Check for jti replay
const isNew = await checkDPoPReplay(jti);
if (!isNew) {
throw new Error('DPoP proof replay detected');
}
// Proceed with the request
}

Key considerations for jti tracking:

  • TTL must match maxAge — Set the cache entry TTL to the same value as maxAge (default: 60 seconds). Entries expire automatically.
  • Distributed cache required — In multi-server deployments, use a shared cache (Redis, Memcached) so all servers see the same jti values.
  • High cardinality — Each DPoP proof has a unique jti. For high-traffic APIs, ensure your cache can handle the volume.

Authorization servers may issue a DPoP-Nonce to prevent pre-generated proofs. When your resource server needs to enforce nonces:

// 1. Generate and store a nonce
const nonce = generateSecureNonce();
storeNonce(nonce);
// 2. Return the nonce to the client in the response header
res.setHeader('DPoP-Nonce', nonce);
// 3. On subsequent requests, verify the nonce
const dpopResult = await authrim.validateDPoP(dpopProof, {
method: req.method,
uri: requestUrl,
expectedNonce: getStoredNonce(),
});

When a client sends a request without the expected nonce, respond with:

res.status(401).json({ error: 'use_dpop_nonce' });
res.setHeader('DPoP-Nonce', newNonce);

The client should retry the request with a new DPoP proof that includes the nonce.

validateDPoP() returns these codes in dpopResult.errorCode for common proof validation failures:

CodeDescription
dpop_proof_invalidGeneral DPoP proof validation failure, including malformed proofs, unsupported algorithms, missing claims, or private key parameters in the proof JWK
dpop_proof_signature_invalidProof signature verification failed
dpop_binding_mismatchProof key does not match the token’s cnf.jkt
dpop_iat_expiredProof iat is beyond maxAge plus clock tolerance
dpop_method_mismatchProof htm does not match the request method
dpop_uri_mismatchProof htu does not match the request URI
dpop_ath_mismatchProof ath does not match the access token
dpop_nonce_requiredProof nonce does not match the expected nonce