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:
- The proof is signed by the key bound to the access token
- The proof matches the current HTTP method and URL
- The proof has not been replayed
Resource Server’s Role
As a resource server using @authrim/server, your DPoP responsibilities are:
| Responsibility | Handled by SDK | Handled by Application |
|---|---|---|
| Parse DPoP proof JWT | Yes | — |
| Verify proof signature | Yes | — |
Validate typ, alg, jwk header | Yes | — |
Validate htm, htu, iat claims | Yes | — |
Verify ath (access token hash) | Yes | — |
Verify JWK Thumbprint binding (cnf.jkt) | Yes | — |
| Reject private key parameters in JWK | Yes | — |
jti replay protection | — | Yes |
| DPoP nonce management | — | Yes |
validateDPoP API
const dpopResult = await authrim.validateDPoP(proof, options);Parameters
| Parameter | Type | Description |
|---|---|---|
proof | string | The raw DPoP proof JWT from the DPoP request header |
options | DPoPValidationOptions | Validation parameters |
DPoPValidationOptions
interface DPoPValidationOptions { method: string; url: string; accessTokenHash?: string; expectedThumbprint?: string; allowedAlgorithms?: string[]; maxAgeSeconds?: number; expectedNonce?: string;}| Option | Type | Default | Description |
|---|---|---|---|
method | string | — | HTTP method of the current request (e.g., 'GET', 'POST') |
url | string | — | Full URL of the current request (scheme + host + path) |
accessTokenHash | string | — | Base64url-encoded SHA-256 hash of the access token for ath verification |
expectedThumbprint | string | — | Expected JWK Thumbprint from the token’s cnf.jkt claim |
allowedAlgorithms | string[] | All supported | Restrict accepted proof signing algorithms |
maxAgeSeconds | number | 300 | Maximum age of the proof in seconds (based on iat) |
expectedNonce | string | — | Server-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 JWKconst thumbprint = await calculateJwkThumbprint(publicJwk);
// Verify a thumbprint matches a JWK (timing-safe)const isValid = await verifyJwkThumbprint(publicJwk, expectedThumbprint);The thumbprint calculation follows RFC 7638:
- Extract the required members for the key type (
kty,crv,x,yfor EC;kty,e,nfor RSA) - Serialize as a JSON object with sorted keys and no whitespace
- Compute the SHA-256 hash
- Encode as Base64url
jti Replay Protection
// Example: DPoP jti replay protection with Redisconst 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 handlerasync 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 asmaxAgeSeconds(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
jtivalues. - 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 nonceconst nonce = generateSecureNonce();storeNonce(nonce);
// 2. Return the nonce to the client in the response headerres.setHeader('DPoP-Nonce', nonce);
// 3. On subsequent requests, verify the nonceconst 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
| Error | Description |
|---|---|
DPoPProofError | General DPoP proof validation failure |
DPoPAlgorithmError | Proof uses alg: none or an unsupported algorithm |
DPoPSignatureError | Proof signature verification failed |
DPoPThumbprintMismatchError | Proof key does not match the token’s cnf.jkt |
DPoPExpiredError | Proof iat is beyond maxAgeSeconds |
DPoPMethodMismatchError | Proof htm does not match the request method |
DPoPUrlMismatchError | Proof htu does not match the request URL |
DPoPNonceMismatchError | Proof nonce does not match the expected nonce |
DPoPPrivateKeyError | Proof JWK contains private key parameters |
Next Steps
- Token Validation — JWT validation pipeline and claims processing
- JWKS Management — Key discovery, caching, and rotation
- Security Considerations — Production security checklist and best practices
- Introspection & Revocation — Query and revoke tokens at the authorization server