Skip to content

DPoP Validation

What is DPoP?

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

Resource Server’s Role

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

validateDPoP API

const dpopResult = await authrim.validateDPoP(proof, options);

Parameters

ParameterTypeDescription
proofstringThe raw DPoP proof JWT from the DPoP request header
optionsDPoPValidationOptionsValidation parameters

DPoPValidationOptions

interface DPoPValidationOptions {
method: string;
url: string;
accessTokenHash?: string;
expectedThumbprint?: string;
allowedAlgorithms?: string[];
maxAgeSeconds?: number;
expectedNonce?: string;
}
OptionTypeDefaultDescription
methodstringHTTP method of the current request (e.g., 'GET', 'POST')
urlstringFull URL of the current request (scheme + host + path)
accessTokenHashstringBase64url-encoded SHA-256 hash of the access token for ath verification
expectedThumbprintstringExpected JWK Thumbprint from the token’s cnf.jkt claim
allowedAlgorithmsstring[]All supportedRestrict accepted proof signing algorithms
maxAgeSecondsnumber300Maximum age of the proof in seconds (based on iat)
expectedNoncestringServer-issued nonce that must be present in the proof

DPoP Validation Flow

flowchart TD
    A["Receive DPoP Proof"] --> B["Parse JWT Header
(typ: dpop+jwt)"] B --> C["Check Algorithm
(reject none, check allowlist)"] C --> D["Extract Public Key
(reject private key params)"] D --> E["Verify Proof Signature"] E --> F["Validate htm
(matches HTTP method)"] F --> G["Validate htu
(matches request URL)"] G --> H["Validate iat
(within maxAgeSeconds)"] 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
(timing-safe)"] L -->|No| N["Skip binding check"] M --> O["Return DPoP Result"] N --> O

Complete DPoP Validation Example

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

import { AuthrimServer } from '@authrim/server';
const authrim = new AuthrimServer({
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);
// 3. If token is DPoP-bound, validate the proof
if (tokenResult.tokenType === 'DPoP' && dpopProof) {
await authrim.validateDPoP(dpopProof, {
method: req.method,
url: `${req.protocol}://${req.hostname}${req.path}`,
accessTokenHash: await computeAccessTokenHash(accessToken),
expectedThumbprint: tokenResult.claims.cnf?.jkt,
});
}
return tokenResult;
}

JWK Thumbprint Verification (RFC 7638)

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

jti Replay Protection

// Example: DPoP jti replay protection with Redis
const DPOP_JTI_TTL = 300; // 5 minutes (matches maxAgeSeconds)
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);
const dpopResult = await authrim.validateDPoP(dpopProof, { /* ... */ });
// Check for jti replay
const isNew = await checkDPoPReplay(dpopResult.jti);
if (!isNew) {
throw new Error('DPoP proof replay detected');
}
// Proceed with the request
}

Key considerations for jti tracking:

  • TTL must match maxAgeSeconds — Set the cache entry TTL to the same value as maxAgeSeconds (default: 300 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.

DPoP Nonce Handling

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,
url: 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.

Security: Private Key Rejection

DPoP Error Types

ErrorDescription
DPoPProofErrorGeneral DPoP proof validation failure
DPoPAlgorithmErrorProof uses alg: none or an unsupported algorithm
DPoPSignatureErrorProof signature verification failed
DPoPThumbprintMismatchErrorProof key does not match the token’s cnf.jkt
DPoPExpiredErrorProof iat is beyond maxAgeSeconds
DPoPMethodMismatchErrorProof htm does not match the request method
DPoPUrlMismatchErrorProof htu does not match the request URL
DPoPNonceMismatchErrorProof nonce does not match the expected nonce
DPoPPrivateKeyErrorProof JWK contains private key parameters

Next Steps