DPoP Deep Dive: The Complete Guide to Making Stolen OAuth Tokens Useless
Your access tokens are bearer tokens. That means anyone who has the token string — whether they stole it from a log file, a compromised CDN, an XSS vulnerability, or a man-in-the-middle attack — can use it exactly as if they were your legitimate user. The token doesn't know who's holding it. It doesn't care.
This is the foundational security weakness of modern OAuth deployments, and it's been an open secret for a decade. Every security audit flags it. Every threat model acknowledges it. And until recently, the practical mitigations were either too complex (mTLS for browser clients?) or too limited (short token lifetimes just reduce the blast radius, they don't prevent the blast).
DPoP — Demonstrating Proof-of-Possession — changes the equation. Defined in RFC 9449 and now one of the two approved mechanisms (alongside mTLS) for the mandatory sender-constrained tokens in FAPI 2.0, DPoP cryptographically binds tokens to a client-held private key. Possession of the token alone is no longer sufficient. Every API request must also include a fresh cryptographic proof that the caller holds the original private key. Stolen tokens become inert strings.
This guide covers everything: why bearer tokens are fundamentally broken, how DPoP's cryptography works, full TypeScript implementations for both client and server, nonce handling for replay protection, key storage strategies across platforms, and a practical migration path from bearer to sender-constrained tokens.
The Bearer Token Problem
Bearer tokens work like cash. Whoever holds the bill can spend it. There's no ID check, no PIN, no biometric. This was a deliberate design choice — RFC 6750 explicitly defines a bearer token as one where "any party in possession of a bearer token can use it to get access to the associated resources (without demonstrating possession of a cryptographic key)."
That simplicity made OAuth 2.0 adoption fast. It also made token theft devastatingly effective.
How Tokens Get Stolen
The attack surface is wide:
Token Leak Vectors:
1. XSS → document.cookie or localStorage read
2. Log aggregation → tokens in URL params or headers appear in logs
3. CDN/Proxy → intermediate services cache or log Authorization headers
4. Browser extensions → malicious extensions read request headers
5. Man-in-the-middle → compromised TLS termination
6. Dependency supply chain → compromised npm package exfiltrates tokens
The 2025 Verizon DBIR reports that token theft accounts for 31% of MFA bypass techniques in enterprise environments, while stolen credentials remain present in 22% of all breaches. Short-lived tokens reduce the window, but a 15-minute access token is still 15 minutes of full API access for an attacker. Refresh token rotation helps, but if the refresh token itself is stolen before rotation, the attacker has long-term access.
Why Existing Mitigations Fall Short
| Mitigation | Limitation |
|---|---|
| Short token lifetimes | Reduces window but doesn't prevent theft. 5-minute tokens still give 5 minutes of access. |
| Refresh token rotation | Race condition: attacker uses token before rotation. Also fails if refresh token is stolen at issuance. |
| Token binding (RFC 8471) | Never achieved broad browser support. Effectively dead. |
| mTLS (RFC 8705) | Excellent security but impractical for browser clients. Certificate management overhead is massive. |
| HttpOnly cookies | Protects against XSS but introduces CSRF risk and doesn't work for cross-origin APIs. |
DPoP occupies the sweet spot: application-layer security that works in browsers, mobile apps, and server-to-server flows without requiring TLS client certificates.
How DPoP Works: The Cryptography
DPoP is conceptually simple: the client generates a key pair, proves it holds the private key with every request, and the server binds the token to that key. Here's the flow:
DPoP Flow:
1. Client generates asymmetric key pair (e.g., EC P-256)
2. Client requests token from Authorization Server
→ includes DPoP proof JWT signed with private key
→ proof contains: HTTP method, target URL, unique ID, timestamp
3. Authorization Server validates proof, issues token
→ token contains 'cnf' claim with public key thumbprint
4. Client calls Resource Server
→ sends token + NEW DPoP proof (signed, fresh, with token hash)
5. Resource Server validates:
→ proof signature matches the key bound to token
→ HTTP method and URL match the actual request
→ proof is fresh (timestamp + optional nonce)
→ token hash matches the presented token
The critical insight: even if an attacker steals the access token, they cannot generate valid DPoP proofs without the private key. The token is cryptographically useless to anyone except the original client.
The DPoP Proof JWT
Every request includes a DPoP proof — a signed JWT with a specific structure:
DPoP Proof JWT Structure:
HEADER:
{
"typ": "dpop+jwt", // MUST be exactly this
"alg": "ES256", // Asymmetric algorithm (ES256, RS256, etc.)
"jwk": { // Public key (for token requests)
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "..."
}
}
PAYLOAD:
{
"htm": "POST", // HTTP method of the request
"htu": "https://auth.example.com/token", // Target URL
"iat": 1712400000, // Issued at (Unix timestamp)
"jti": "unique-id-abc123", // Unique ID (prevents replay)
"ath": "fUHyO2r2Z3DZ..." // Access token hash (for resource requests)
"nonce": "server-nonce" // Server-provided nonce (if required)
}
Two critical claims differentiate DPoP from regular JWTs:
-
ath(Access Token Hash): Present only when calling resource servers. It's the base64url-encoded SHA-256 hash of the access token. This binds the proof to a specific token, preventing proof reuse across different tokens. -
nonce: An opaque value provided by the server in aDPoP-Nonceresponse header. Enables server-side replay protection beyond thejtiuniqueness check.
Full Implementation: Client Side
Let's build a complete DPoP client in TypeScript. We'll use the Web Crypto API for key generation and the jose library for JWT operations.
Key Pair Generation
// dpop-client.ts import { SignJWT, exportJWK, calculateJwkThumbprint } from 'jose'; import { v4 as uuidv4 } from 'uuid'; interface DPoPKeyPair { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey; thumbprint: string; } async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { // Generate a non-extractable EC P-256 key pair const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', }, false, // non-extractable: private key cannot be exported ['sign', 'verify'] ); const publicJwk = await exportJWK(keyPair.publicKey); const thumbprint = await calculateJwkThumbprint( publicJwk as Parameters<typeof calculateJwkThumbprint>[0], 'sha256' ); return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, publicJwk, thumbprint, }; }
The false parameter in generateKey is critical — it marks the private key as non-extractable. Even JavaScript code running in the same context cannot read the raw key material. The key exists only inside the browser's crypto engine.
Creating DPoP Proofs
interface DPoPProofOptions { keyPair: DPoPKeyPair; method: string; url: string; accessToken?: string; // Required for resource server requests nonce?: string; // Server-provided nonce } async function createDPoPProof(options: DPoPProofOptions): Promise<string> { const { keyPair, method, url, accessToken, nonce } = options; // Build payload const payload: Record<string, unknown> = { htm: method.toUpperCase(), htu: url, jti: uuidv4(), iat: Math.floor(Date.now() / 1000), }; // Add access token hash for resource server requests if (accessToken) { const encoder = new TextEncoder(); const tokenBytes = encoder.encode(accessToken); const hashBuffer = await crypto.subtle.digest('SHA-256', tokenBytes); const hashArray = new Uint8Array(hashBuffer); payload.ath = base64urlEncode(hashArray); } // Add server-provided nonce if available if (nonce) { payload.nonce = nonce; } // Sign the proof const proof = await new SignJWT(payload) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: keyPair.publicJwk, }) .sign(keyPair.privateKey); return proof; } function base64urlEncode(buffer: Uint8Array): string { const binary = String.fromCharCode(...buffer); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); }
The DPoP-Aware HTTP Client
Here's the crucial piece — an HTTP client wrapper that handles the full DPoP lifecycle including automatic nonce retry:
class DPoPClient { private keyPair: DPoPKeyPair | null = null; private nonces: Map<string, string> = new Map(); // origin → nonce async initialize(): Promise<void> { this.keyPair = await generateDPoPKeyPair(); } async fetch( url: string, options: RequestInit & { accessToken?: string } = {} ): Promise<Response> { if (!this.keyPair) throw new Error('DPoP client not initialized'); const method = options.method || 'GET'; const origin = new URL(url).origin; // Generate DPoP proof const proof = await createDPoPProof({ keyPair: this.keyPair, method, url, accessToken: options.accessToken, nonce: this.nonces.get(origin), }); // Build headers const headers = new Headers(options.headers); headers.set('DPoP', proof); if (options.accessToken) { headers.set('Authorization', `DPoP ${options.accessToken}`); } const response = await fetch(url, { ...options, headers }); // Handle nonce challenges const newNonce = response.headers.get('DPoP-Nonce'); if (newNonce) { this.nonces.set(origin, newNonce); // If we got a 401 with use_dpop_nonce error, retry with the new nonce if (response.status === 401 || response.status === 400) { const wwwAuth = response.headers.get('WWW-Authenticate') || ''; if (wwwAuth.includes('use_dpop_nonce')) { return this.fetch(url, options); // Retry with stored nonce } } } return response; } }
Token Request with DPoP
async function requestToken( client: DPoPClient, tokenEndpoint: string, authCode: string, codeVerifier: string ): Promise<{ access_token: string; refresh_token: string }> { const response = await client.fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, code_verifier: codeVerifier, client_id: 'my-spa-client', redirect_uri: 'https://app.example.com/callback', }).toString(), }); if (!response.ok) { throw new Error(`Token request failed: ${response.status}`); } return response.json(); }
Notice the Authorization scheme changes from Bearer to DPoP — this signals the resource server to expect and validate a DPoP proof.
Full Implementation: Server Side
The server side has two responsibilities: binding tokens to keys during issuance, and validating proofs during resource access.
Token Issuance (Authorization Server)
import { jwtVerify, importJWK, calculateJwkThumbprint } from 'jose'; interface DPoPProofPayload { htm: string; htu: string; jti: string; iat: number; ath?: string; nonce?: string; } async function validateDPoPProof( proofJwt: string, expectedMethod: string, expectedUrl: string, expectedNonce?: string, accessToken?: string ): Promise<{ thumbprint: string }> { // 1. Decode header WITHOUT verifying yet (to extract the public key) const [headerB64] = proofJwt.split('.'); const header = JSON.parse(atob(headerB64)); // 2. Validate header if (header.typ !== 'dpop+jwt') { throw new Error('Invalid typ: must be dpop+jwt'); } if (!header.jwk) { throw new Error('Missing jwk in header'); } if (header.alg === 'none' || header.alg.startsWith('HS')) { throw new Error('Symmetric algorithms not allowed for DPoP'); } // 3. Import the public key and verify signature const publicKey = await importJWK(header.jwk, header.alg); const { payload } = await jwtVerify(proofJwt, publicKey, { typ: 'dpop+jwt', maxTokenAge: '60s', // Reject proofs older than 60 seconds }); const claims = payload as unknown as DPoPProofPayload; // 4. Validate HTTP method and URL if (claims.htm !== expectedMethod) { throw new Error(`htm mismatch: expected ${expectedMethod}, got ${claims.htm}`); } if (claims.htu !== expectedUrl) { throw new Error(`htu mismatch: expected ${expectedUrl}, got ${claims.htu}`); } // 5. Validate jti uniqueness (check against a replay cache) const isReplay = await checkAndStoreJti(claims.jti, 300); // 5-min window if (isReplay) { throw new Error('DPoP proof replay detected'); } // 6. Validate nonce if required if (expectedNonce && claims.nonce !== expectedNonce) { throw new Error('Invalid or missing DPoP nonce'); } // 7. Validate access token hash (for resource server requests) if (accessToken) { const expectedAth = await computeAth(accessToken); if (claims.ath !== expectedAth) { throw new Error('Access token hash mismatch'); } } // 8. Compute JWK thumbprint for token binding const thumbprint = await calculateJwkThumbprint(header.jwk, 'sha256'); return { thumbprint }; } async function computeAth(accessToken: string): Promise<string> { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest( 'SHA-256', encoder.encode(accessToken) ); return base64urlEncode(new Uint8Array(hashBuffer)); } // Simple Redis-based JTI replay cache async function checkAndStoreJti( jti: string, windowSeconds: number ): Promise<boolean> { const key = `dpop:jti:${jti}`; const exists = await redis.exists(key); if (exists) return true; await redis.setex(key, windowSeconds, '1'); return false; }
Token with Confirmation Claim
When the authorization server issues a DPoP-bound token, it includes a cnf (confirmation) claim containing the JWK thumbprint:
async function issueToken( userId: string, dpopThumbprint: string, scopes: string[] ): Promise<string> { const token = await new SignJWT({ sub: userId, scope: scopes.join(' '), cnf: { jkt: dpopThumbprint, // JWK Thumbprint confirmation }, token_type: 'DPoP', }) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setExpirationTime('15m') .setIssuer('https://auth.example.com') .setAudience('https://api.example.com') .sign(serverPrivateKey); return token; }
Resource Server Validation
// middleware/dpop-validator.ts import { jwtVerify, decodeJwt } from 'jose'; async function validateDPoPRequest(req: Request): Promise<void> { // 1. Extract the DPoP proof from headers const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { throw new DPoPError(401, 'Missing DPoP proof header'); } // 2. Extract the access token const authHeader = req.headers.get('Authorization'); if (!authHeader?.startsWith('DPoP ')) { throw new DPoPError(401, 'Invalid authorization scheme, expected DPoP'); } const accessToken = authHeader.slice(5); // 3. Decode the access token to get the bound thumbprint const tokenClaims = decodeJwt(accessToken); const boundThumbprint = (tokenClaims.cnf as { jkt: string })?.jkt; if (!boundThumbprint) { throw new DPoPError(401, 'Token is not DPoP-bound (missing cnf.jkt)'); } // 4. Validate the DPoP proof const requestUrl = new URL(req.url).origin + new URL(req.url).pathname; const { thumbprint } = await validateDPoPProof( dpopProof, req.method, requestUrl, getCurrentNonce(), // Server's current nonce accessToken ); // 5. Verify the proof was signed by the key bound to the token if (thumbprint !== boundThumbprint) { throw new DPoPError(401, 'DPoP proof key does not match token binding'); } } class DPoPError extends Error { constructor( public status: number, message: string ) { super(message); } }
Nonce Management for Replay Protection
The jti claim provides client-side uniqueness, but a determined attacker with access to the proof in transit could replay it within the time window. Server-provided nonces add a second layer:
How Nonces Work
Nonce Challenge Flow:
Client → Resource Server: DPoP proof (no nonce)
Resource Server → Client: 401 + DPoP-Nonce: "abc123"
Client → Resource Server: DPoP proof (nonce: "abc123")
Resource Server → Client: 200 OK + DPoP-Nonce: "def456"
Client → Resource Server: DPoP proof (nonce: "def456")
...continues, nonce rotates with each response...
Server-Side Nonce Implementation
class NonceManager { private currentNonce: string; private previousNonce: string | null = null; private rotationInterval: NodeJS.Timeout; constructor(rotationSeconds: number = 30) { this.currentNonce = this.generate(); // Rotate nonces periodically this.rotationInterval = setInterval(() => { this.previousNonce = this.currentNonce; this.currentNonce = this.generate(); }, rotationSeconds * 1000); } private generate(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64urlEncode(bytes); } getCurrent(): string { return this.currentNonce; } isValid(nonce: string): boolean { // Accept current or previous nonce (grace period during rotation) return nonce === this.currentNonce || nonce === this.previousNonce; } } // Express middleware function dpopNonceMiddleware(nonceManager: NonceManager) { return (req: Request, res: Response, next: NextFunction) => { // Always include the current nonce in responses res.setHeader('DPoP-Nonce', nonceManager.getCurrent()); next(); }; }
Important Nonce Rules
-
Nonces are per-server. Authorization server nonces and resource server nonces are completely separate. Never reuse a nonce from one server when talking to another.
-
Nonces are opaque. Clients must not parse, decode, or interpret nonce values. Treat them as opaque strings.
-
Accept the previous nonce. During rotation, accept both the current and the immediately previous nonce to avoid breaking in-flight requests.
-
Always send the nonce header. Include
DPoP-Noncein every response, even successful ones. This lets clients pre-populate the nonce for the next request without a failed roundtrip.
Key Storage Strategies
The security of DPoP depends entirely on the private key remaining un-extractable. Different platforms require different strategies:
Browser (SPA)
// Use IndexedDB for persistent key storage async function persistKeyPair(keyPair: CryptoKeyPair): Promise<void> { const db = await openDB('dpop-keys', 1, { upgrade(db) { db.createObjectStore('keys'); }, }); // CryptoKey objects can be stored directly in IndexedDB // They remain non-extractable even when persisted await db.put('keys', keyPair, 'dpop-keypair'); } async function loadKeyPair(): Promise<CryptoKeyPair | null> { const db = await openDB('dpop-keys', 1); return db.get('keys', 'dpop-keypair'); }
Key property: CryptoKey objects stored in IndexedDB retain their extractable: false property. The raw key material is never exposed to JavaScript, even across page reloads.
Mobile (React Native / Native)
| Platform | Storage | Security Level |
|---|---|---|
| iOS | Secure Enclave (Keychain) | Hardware-backed, tamper-proof |
| Android | Android Keystore (StrongBox if available) | Hardware-backed on supported devices |
| React Native | react-native-keychain + platform-specific backing | Depends on underlying platform |
Server-to-Server
For backend services, the key pair can be loaded from environment variables or a key management service (KMS):
// Use environment variable or KMS const privateKey = await importPKCS8( process.env.DPOP_PRIVATE_KEY!, 'ES256' );
Migration: Bearer to DPoP
You cannot flip a switch and require DPoP from all clients overnight. Here's a phased approach:
Phase 1: Dual Support
Accept both bearer and DPoP tokens. New clients use DPoP; existing clients continue with bearer tokens.
async function validateRequest(req: Request): Promise<TokenClaims> { const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('DPoP ')) { // DPoP flow: validate proof + token binding await validateDPoPRequest(req); return decodeAndVerifyToken(authHeader.slice(5)); } if (authHeader?.startsWith('Bearer ')) { // Legacy bearer flow: still accepted metrics.increment('auth.bearer.used'); // Track for deprecation return decodeAndVerifyToken(authHeader.slice(7)); } throw new Error('Missing or invalid Authorization header'); }
Phase 2: DPoP Preferred
Issue DPoP-bound tokens by default. Log bearer token usage for monitoring.
// Authorization server token endpoint app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (dpopProof) { // Client supports DPoP: issue sender-constrained token const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); } else { // Legacy client: issue bearer token with deprecation warning const token = await issueBearerToken(userId, scopes); res.setHeader('Deprecation', 'true'); res.json({ access_token: token, token_type: 'bearer' }); } });
Phase 3: DPoP Required
After monitoring confirms low bearer usage, enforce DPoP for all clients.
app.post('/token', async (req, res) => { const dpopProof = req.headers.get('DPoP'); if (!dpopProof) { return res.status(400).json({ error: 'invalid_dpop_proof', error_description: 'DPoP proof required. Bearer tokens are no longer accepted.', }); } const { thumbprint } = await validateDPoPProof(dpopProof, 'POST', tokenUrl); const token = await issueToken(userId, thumbprint, scopes); res.json({ access_token: token, token_type: 'DPoP' }); });
Migration Metrics
| Metric | Target | What It Tells You |
|---|---|---|
| DPoP adoption rate | >90% before Phase 3 | Overall migration progress |
| Bearer token usage | Declining to <5% | Whether legacy clients are updating |
| Nonce retry rate | <10% of requests | If nonce rotation is too aggressive |
| Proof validation failures | <0.1% | Clock skew or implementation bugs |
| Key rotation events | Tracked per client | Key lifecycle management health |
DPoP vs. Other Token Binding Approaches
| Feature | Bearer Token | DPoP (RFC 9449) | mTLS (RFC 8705) | Token Binding (RFC 8471) |
|---|---|---|---|---|
| Token theft protection | ❌ None | ✅ Cryptographic binding | ✅ TLS binding | ✅ TLS binding |
| Browser support | ✅ Universal | ✅ Web Crypto API | ❌ No client cert UI | ❌ Abandoned by browsers |
| Mobile support | ✅ Universal | ✅ Platform crypto | ⚠️ Complex cert mgmt | ❌ Not implemented |
| Implementation complexity | ⭐ Simple | ⭐⭐ Moderate | ⭐⭐⭐ High | N/A (Dead) |
| Performance overhead | None | ~2ms per request (signing) | TLS handshake cost | N/A |
| Works with CDN/proxy | ✅ Yes | ✅ Yes (app layer) | ⚠️ TLS termination issues | ❌ TLS termination breaks it |
| Standard maturity | RFC 6750 (2012) | RFC 9449 (2023) | RFC 8705 (2020) | Abandoned |
DPoP wins for web and mobile because it operates at the application layer. No special TLS configuration, no client certificates, no browser UI changes. Just cryptography in JavaScript.
Production Security Checklist
Client-Side
-
Use non-extractable keys. Always set
extractable: falseincrypto.subtle.generateKey(). This prevents any JavaScript code — including injected XSS payloads — from reading the raw private key. -
Rotate keys periodically. Generate a new key pair when users re-authenticate or sessions start. Old token bindings become invalid automatically.
-
Use IndexedDB, not localStorage.
CryptoKeyobjects can only be stored in IndexedDB. localStorage can only store strings, which would require extracting the key — defeating the purpose. -
Handle clock skew. The
iatclaim must be within the server's tolerance window. If your client's clock is off, proofs will be rejected. UseDate.now()and accept that server-side validation should allow ±60 seconds of skew.
Server-Side
-
Maintain a JTI replay cache. Use Redis or a similar store with TTL matching your proof acceptance window. Without this, proofs can be replayed within the time window.
-
Validate everything. Check
typ,alg(reject symmetric algorithms),htm,htu,iat,jti,ath, andnonce. Missing any validation step creates a bypass. -
Use nonces in high-security contexts. For financial APIs or FAPI 2.0 compliance, server-provided nonces are mandatory. For general APIs, they add security but increase latency by one round-trip on first request.
-
Return nonces on every response. Don't require clients to fail before learning the nonce. Include the
DPoP-Nonceheader on all responses so clients can pre-populate it. -
Reject
alg: noneand symmetric algorithms. The DPoP proof MUST use an asymmetric algorithm. AcceptingHS256defeats the entire purpose — it would mean the server and client share a secret, which is exactly what DPoP is designed to avoid.
Performance and Cost Model
DPoP adds cryptographic operations to every request. Here's the real-world impact:
| Operation | Time (P50) | Time (P99) | Notes |
|---|---|---|---|
| Key generation (EC P-256) | 0.5ms | 2ms | Once per session |
| Proof signing (ECDSA) | 0.8ms | 2.5ms | Every request |
| Proof verification (server) | 0.3ms | 1ms | Every request |
| JTI cache lookup (Redis) | 0.1ms | 0.5ms | Every request |
| Thumbprint calculation | 0.1ms | 0.3ms | Token issuance |
Total overhead per request: ~1.3ms client-side, ~0.5ms server-side. For context, a typical database query takes 5-50ms. DPoP's overhead is noise level.
The trade-off is clear: ~2ms of additional latency per request eliminates the entire class of token theft attacks.
The 2026 Reality
DPoP is not a future standard — it's a present requirement. RFC 9449 was published in September 2023 and has become one of the two required sender-constraint mechanisms for FAPI 2.0 compliance in financial services. Auth0 and Okta provide first-class DPoP support, and Microsoft Entra ID offers its own Proof-of-Possession token binding. The WebCrypto API is available in every major browser.
The real barrier is inertia. Teams that have built entire authentication systems around bearer tokens hesitate to add the complexity of cryptographic proofs. But the implementation is not complex — it's a key pair, a JWT per request, and validation middleware. The jose library handles the heavy lifting. The DPoP-aware fetch wrapper shown above is under 50 lines of code.
Bearer tokens were designed for simplicity in an era when XSS was less prevalent, supply chain attacks were rare, and API architectures were simpler. That era is over. Every token you issue as a plain bearer token is a token that can be stolen and replayed with zero friction.
Start with your highest-value API endpoints — authentication, billing, admin operations. Add DPoP support alongside bearer tokens. Monitor which clients upgrade. When coverage is high enough, deprecate bearer tokens entirely.
Your tokens should prove who's holding them. DPoP makes that possible with a few hundred lines of code and negligible latency. The only cost of not doing it is waiting for the breach.
Explore Related Tools
Try these free developer tools from Pockit