DPoP Validation
What is DPoP?
Section titled “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
Section titled “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
Section titled “validateDPoP API”const dpopResult = await authrim.validateDPoP(proof, options);Parameters
Section titled “Parameters”| Parameter | Type | Description |
|---|---|---|
proof | string | The raw DPoP proof JWT from the DPoP request header |
options | DPoPValidationOptions | Validation parameters |
DPoPValidationOptions
Section titled “DPoPValidationOptions”interface DPoPValidationOptions { method: string; uri: string; accessToken?: string; expectedThumbprint?: string; expectedNonce?: string; maxAge?: number; clockTolerance?: number;}| Option | Type | Default | Description |
|---|---|---|---|
method | string | — | HTTP method of the current request (e.g., 'GET', 'POST') |
uri | string | — | Full request URI. The SDK normalizes it to scheme + host + path for htu comparison |
accessToken | string | — | Access token used to verify the proof ath claim |
expectedThumbprint | string | — | Expected JWK Thumbprint from the token’s cnf.jkt claim |
expectedNonce | string | — | Server-issued nonce that must be present in the proof |
maxAge | number | 60 | Maximum age of the proof in seconds (based on iat) |
clockTolerance | number | 60 | Clock skew tolerance in seconds |
The current SDK accepts ES256, PS256, and EdDSA DPoP proof algorithms.
DPoP Validation Flow
Section titled “DPoP Validation Flow”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
Complete DPoP Validation Example
Section titled “Complete DPoP Validation Example”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;}JWK Thumbprint Verification (RFC 7638)
Section titled “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
Section titled “jti Replay Protection”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 Redisconst 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 handlerasync 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 asmaxAge(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
jtivalues. - High cardinality — Each DPoP proof has a unique
jti. For high-traffic APIs, ensure your cache can handle the volume.
DPoP Nonce Handling
Section titled “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, 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.
Security: Private Key Rejection
Section titled “Security: Private Key Rejection”DPoP Error Codes
Section titled “DPoP Error Codes”validateDPoP() returns these codes in dpopResult.errorCode for common proof validation failures:
| Code | Description |
|---|---|
dpop_proof_invalid | General DPoP proof validation failure, including malformed proofs, unsupported algorithms, missing claims, or private key parameters in the proof JWK |
dpop_proof_signature_invalid | Proof signature verification failed |
dpop_binding_mismatch | Proof key does not match the token’s cnf.jkt |
dpop_iat_expired | Proof iat is beyond maxAge plus clock tolerance |
dpop_method_mismatch | Proof htm does not match the request method |
dpop_uri_mismatch | Proof htu does not match the request URI |
dpop_ath_mismatch | Proof ath does not match the access token |
dpop_nonce_required | Proof nonce does not match the expected nonce |
Next Steps
Section titled “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