Hardening OIDC Flows: Implementing DPoP for Single Page Applications
For years, the security of Single Page Applications (SPAs) has relied on a fragile balance between developer experience and risk mitigation. Traditional OAuth 2.0 Bearer tokens are "bearer" instruments: anyone who possesses the token can use it. If an attacker exfiltrates a token via Cross-Site Scripting (XSS) or a compromised dependency, the game is over until the token expires.
As of mid-2026, the industry has shifted decisively toward Demonstrating Proof-of-Possession (DPoP). DPoP (RFC 9449) moves us beyond bearer tokens by cryptographically binding access and refresh tokens to a specific client-generated key pair. Even if a token is stolen, it is useless without the corresponding private key stored in the browser's non-extractable storage.
The Vulnerability of Bearer Tokens
In a standard Authorization Code Flow with PKCE, the SPA receives an access_token. This token is typically sent in the Authorization: Bearer <token> header. The Resource Server (RS) validates the signature and claims but has no way to verify that the sender is the same client that originally requested the token.
Attackers targeting SPAs often use malicious npm packages or supply chain attacks to inject scripts that scrape localStorage or intercept memory. Once the bearer token is sent to an attacker-controlled C2 server, they can replay that token from any environment until it expires.
How DPoP Solves Replay Attacks
DPoP introduces a mechanism where the client proves possession of a private key for every request. The process follows three main steps:
- Key Generation: The SPA generates an ephemeral asymmetric key pair (usually ECDSA P-256) using the Web Crypto API. The private key is marked as
extractable: false. - DPoP Proof: For every request to the Authorization Server (AS) or Resource Server (RS), the client generates a unique, short-lived JWT (the DPoP proof) signed by its private key. This proof contains a
htm(HTTP method),htu(HTTP URI), andiat(issued at) claim. - Binding: The AS issues a token that contains a thumbprint (
cnfclaim) of the client's public key. The RS then verifies that the DPoP proof signature matches the thumbprint embedded in the access token.
Implementing DPoP in TypeScript
To implement DPoP, you must handle key persistence and the generation of the DPoP HTTP header. Using the jose library is the current standard for lightweight JWT operations in the browser.
1. Generating the Persistent Key Pair
You should generate the key once and store it in IndexedDB. Unlike localStorage, IndexedDB can store CryptoKey objects directly, allowing you to use the key without ever exposing the raw private material to JavaScript strings.
async function getOrCreateDPoPKey(): Promise<CryptoKeyPair> {
const db = await openDB('security-store', 1);
let keyPair = await db.get('keys', 'dpop-key');
if (!keyPair) {
keyPair = await window.crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // non-extractable
['sign', 'verify']
);
await db.put('keys', keyPair, 'dpop-key');
}
return keyPair;
}
2. Creating the DPoP Proof
The DPoP proof is a JWT sent in the DPoP header. It must be generated fresh for every request to prevent replay attacks via the proof itself.
import { SignJWT, exportJWK } from 'jose';
async function createDPoPHeader(url: string, method: string, keyPair: CryptoKeyPair): Promise<string> {
const publicJwk = await exportJWK(keyPair.publicKey);
return new SignJWT({
htm: method.toUpperCase(),
htu: url,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
})
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
.sign(keyPair.privateKey);
}
Server-Side Validation Requirements
Transitioning to DPoP requires updates to your Resource Servers. A DPoP-compliant RS must:
- Validate the Access Token: Ensure the token has a
cnf(confirmation) claim containing the JWK thumbprint. - Validate the DPoP Header: Check the signature of the DPoP JWT using the
jwkprovided in its header. - Verify Claims: Ensure
htmandhtumatch the current request. Check thatiatis within a very narrow window (e.g., +/- 60 seconds) to prevent proof replay. - Check for JTI Uniqueness: Ideally, the RS should track
jti(JWT ID) values for the duration of the allowediatwindow to ensure a proof isn't used twice.
Tradeoffs and Considerations
Performance Overhead
Generating a signature for every API call adds a few milliseconds of latency on the client and a few milliseconds of validation time on the server. For high-frequency polling, this can add up. However, for standard REST/GraphQL interactions, the security gain far outweighs the cost.
Key Rotation
Since the keys are stored in IndexedDB, they persist across sessions. If a user clears their browser data, the key is lost. Your application must be prepared to handle 401 Unauthorized errors specifically related to DPoP (e.g., invalid_dpop_proof) by re-initiating the OIDC flow to bind a new key.
Server Support
Not all Identity Providers (IdPs) support DPoP yet. Auth0 and Ory have introduced preview or stable support for DPoP in their latest distributions. Always verify your IdP's capabilities before refactoring your client-side auth logic.
Conclusion
Bearer tokens are a legacy of a simpler web. In an era of complex supply chains and sophisticated XSS, binding tokens to the client hardware/instance via DPoP is no longer optional for high-security applications. By leveraging the Web Crypto API and IndexedDB, we can achieve a level of security previously reserved for native apps with hardware-backed keystores, significantly raising the bar for attackers targeting web-based sessions."}