DPoP (Demonstrating Proof of Possession)
The Problem with Bearer Tokens
The OAuth 2.0 Bearer Token specification (RFC 6750) defines tokens that grant access to anyone who possesses them. The word “bearer” is literal: whoever bears (holds) the token can use it. There is no verification that the presenter of the token is the same party to whom the token was issued.
This creates a class of attacks:
| Attack | Mechanism |
|---|---|
| Token exfiltration via XSS | Malicious JavaScript reads the token from localStorage and sends it to an attacker’s server |
| Token leakage via logs | Access tokens accidentally logged by proxies, CDNs, or application servers |
| Token replay | An intercepted token is replayed from a different device or IP |
| Token theft via compromised middleware | A reverse proxy or API gateway stores tokens for later misuse |
In all these cases, the stolen token works exactly as well in the attacker’s hands as it does in the legitimate client’s hands. The resource server cannot distinguish between the two because bearer tokens carry no proof of who is presenting them.
What DPoP Does
DPoP (Demonstrating Proof of Possession), defined in RFC 9449, solves this problem by binding tokens to the client’s cryptographic key pair. A DPoP-bound token is useless without the corresponding private key.
The core idea:
- The client generates an ephemeral key pair (RSA or EC)
- When requesting a token, the client creates a DPoP proof — a JWT signed with the private key
- The authorization server (Auris) extracts the public key from the proof and binds the issued token to that key via a JWK thumbprint (
jktclaim) - On every API call, the client sends both the bound access token and a fresh DPoP proof signed with the same private key
- The resource server verifies that the proof’s public key matches the token’s
jktbinding
If an attacker steals the access token but not the private key (which is held in memory and never transmitted), the token is worthless — the attacker cannot produce valid DPoP proofs.
How It Works: Step by Step
Step 1: Client Generates an Ephemeral Key Pair
When the client application initializes, it generates an RSA or EC key pair. This key pair is ephemeral — it exists only in the client’s memory (or secure storage) and is never sent to any server.
// Generate an EC key pair (P-256) using the Web Crypto API
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // non-extractable: private key cannot be exported
['sign', 'verify']
)The private key is marked as non-extractable, meaning even the client’s own JavaScript cannot read the raw key material. It can only be used for signing operations.
Step 2: Client Creates a DPoP Proof JWT
Before making a token request, the client creates a DPoP proof — a JWT with specific claims, signed with the private key:
// DPoP Proof JWT Header
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "base64url-encoded-x-coordinate",
"y": "base64url-encoded-y-coordinate"
}
}
// DPoP Proof JWT Payload
{
"jti": "unique-proof-id-abc123",
"htm": "POST",
"htu": "https://auth.example.com/api/auth/token",
"iat": 1739880000,
"nonce": "server-provided-nonce"
}| Claim | Description |
|---|---|
jti | A unique identifier for this proof (prevents replay) |
htm | The HTTP method of the request this proof accompanies |
htu | The HTTP URL of the request (without query/fragment) |
iat | When the proof was created (must be recent) |
nonce | Server-provided nonce for freshness (optional, see Nonce Management) |
jwk (header) | The public key corresponding to the private key used to sign the proof |
The proof is signed with the private key. The public key is embedded in the jwk header so the server can verify the signature.
Step 3: Client Sends DPoP Proof with Token Request
The DPoP proof is sent in the DPoP HTTP header alongside the normal token request:
POST /api/auth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...
grant_type=authorization_code
&code=auth-code-here
&redirect_uri=https://app.example.com/callback
&client_id=your-client-id
&code_verifier=pkce-verifierStep 4: Server Binds the Token to the Key
Auris validates the DPoP proof:
- Verifies the
typheader isdpop+jwt - Verifies the signature using the embedded
jwkpublic key - Checks that
htmmatches the request method andhtumatches the request URL - Checks that
iatis recent (within acceptable clock skew) - Checks
jtiuniqueness (prevents proof replay) - If a nonce was required, validates the
nonceclaim
If validation passes, Auris computes the JWK thumbprint (a hash of the public key per RFC 7638) and includes it as the jkt claim in the issued access token:
// Access token payload (DPoP-bound)
{
"sub": "usr_abc123",
"iss": "https://auth.example.com",
"aud": "your-client-id",
"iat": 1739880000,
"exp": 1739880900,
"cnf": {
"jkt": "JWK-thumbprint-of-the-clients-public-key"
},
"token_type": "DPoP"
}The cnf.jkt (confirmation / JWK thumbprint) claim cryptographically binds this token to the client’s key pair. The token_type is set to DPoP instead of Bearer.
Step 5: Client Sends Bound Token + Fresh Proof on API Calls
For every API call, the client sends both the DPoP-bound access token in the Authorization header and a fresh DPoP proof in the DPoP header:
GET /api/users/me HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJSUzI1NiJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...Note the Authorization scheme is DPoP, not Bearer. Each API call requires a fresh proof with a unique jti, the correct htm/htu for the current request, and a recent iat.
Step 6: Resource Server Verifies the Binding
The resource server:
- Extracts the DPoP proof from the
DPoPheader - Validates the proof (signature,
htm,htu,iat,jti) - Computes the JWK thumbprint of the proof’s
jwkpublic key - Compares it to the
cnf.jktclaim in the access token - If they match, the presenter has the private key — proof of possession confirmed
If the thumbprints do not match, the request is rejected with 401 Unauthorized — the token was issued to a different key pair, indicating token theft.
Nonce Management
Nonces add an additional layer of replay protection. When enabled, the authorization server provides a nonce that the client must include in the next DPoP proof.
How Nonces Work
- Client sends a token request without a nonce
- Server responds with
400and aDPoP-Nonceheader containing a fresh nonce value - Client retries the request, including the nonce in the DPoP proof’s
nonceclaim - Server validates the nonce and processes the request
- Optionally, the server includes a new
DPoP-Nonceheader in the response for the next request
// First attempt (no nonce)
POST /api/auth/token HTTP/1.1
DPoP: eyJ0eXAiOiJkcG9wK2p3dCJ9... // no nonce claim
// Server response
HTTP/1.1 400 Bad Request
DPoP-Nonce: eyJ2IjoiMSIsImlhdCI6MTczOTg4MDAwMH0
Content-Type: application/json
{"error": "use_dpop_nonce"}
// Second attempt (with nonce)
POST /api/auth/token HTTP/1.1
DPoP: eyJ0eXAiOiJkcG9wK2p3dCJ9... // includes "nonce": "eyJ2IjoiMSIs..."Why Nonces Help
Without nonces, a DPoP proof is valid as long as its iat is within the acceptable time window (typically 60 seconds). An attacker who intercepts the proof during transmission has a brief window to replay it. Nonces eliminate this window: a proof is valid only if it contains the server’s most recent nonce, which changes with each interaction.
Auris Nonce Implementation
Auris stores nonces in the DpopNonce Prisma model with a short TTL. Nonces are generated as signed tokens containing a version and timestamp, which allows the server to validate them without a database lookup for the common case.
Nonce management adds one extra round-trip to the first request in a session. For most applications, the iat freshness check alone provides sufficient replay protection. Enable nonces only when protecting against sophisticated network-level attackers (for example, in zero-trust environments or high-value financial APIs).
DPoP vs mTLS
Both DPoP and mTLS (Mutual TLS, RFC 8705) solve the same fundamental problem: binding tokens to the client’s identity. They take different approaches:
| Dimension | DPoP (RFC 9449) | mTLS (RFC 8705) |
|---|---|---|
| Binding mechanism | JWK thumbprint in token + signed proof JWT | Client certificate thumbprint in token (cnf.x5t#S256) |
| Infrastructure requirement | None (pure application-layer) | TLS termination must preserve client cert, or use certificate-bound tokens |
| Client complexity | Generate key pair + sign JWTs | Obtain and manage X.509 certificates |
| Browser support | Works in browsers via Web Crypto API | Not supported in browsers (no client cert API) |
| Certificate management | No certificates needed | Requires PKI or certificate authority |
| Proxy/CDN compatibility | Excellent (headers pass through) | Problematic (TLS termination may strip client cert) |
| Performance | Proof generation is fast (EC signing is ~1ms) | TLS handshake is heavier but amortized |
| Token theft protection | Excellent | Excellent |
When to Use DPoP
- Browser applications (SPAs): DPoP is the only proof-of-possession mechanism that works in browsers
- Mobile applications: Easier to implement than mTLS certificate management
- Microservice-to-microservice: When mTLS infrastructure is not available
- Behind CDNs/load balancers: DPoP works through any proxy since it uses HTTP headers
When to Use mTLS
- Server-to-server with PKI: When your organization already has certificate infrastructure
- Zero-trust networks: Where mutual TLS authentication is required at the transport layer
- Regulatory requirements: Some financial regulations specifically require mTLS
Auris supports DPoP. mTLS is supported at the Keycloak layer for direct Keycloak client integrations.
Auris Implementation Details
Enabling DPoP
DPoP is configured per-application in the Auris Console. Go to Applications → (select application) → Settings:
| Setting | Description |
|---|---|
| Enable DPoP | Master toggle for DPoP support on this application |
| Require DPoP | When enabled, the application rejects non-DPoP token requests |
| Require Nonces | When enabled, all DPoP proofs must include a server-provided nonce |
When DPoP is enabled but not required, the application accepts both DPoP-bound tokens (Authorization: DPoP ...) and plain bearer tokens (Authorization: Bearer ...). This allows gradual migration.
DPoP-Bound M2M Tokens
DPoP is also supported for Machine-to-Machine (M2M) tokens issued via the client_credentials grant. M2M tokens are stored in the M2MToken Prisma model with jkt (JWK thumbprint) and isDpopBound fields.
POST /api/auth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCJ9...
grant_type=client_credentials
&client_id=m2m-client-id
&client_secret=m2m-client-secret
&scope=read:usersThe resulting M2M token is bound to the DPoP key pair, providing proof-of-possession for service-to-service communication.
DPoP Proof Validation
Auris validates DPoP proofs in the dpop-validator.ts middleware with the following checks:
| Check | Failure Response |
|---|---|
typ header must be dpop+jwt | 400 invalid_dpop_proof |
jwk header must contain a valid public key | 400 invalid_dpop_proof |
Signature must be valid using the embedded jwk | 400 invalid_dpop_proof |
htm must match the current request’s HTTP method | 400 invalid_dpop_proof |
htu must match the current request’s URL (scheme + host + path) | 400 invalid_dpop_proof |
iat must be within 60 seconds of server time | 400 invalid_dpop_proof |
jti must not have been used before (replay check) | 400 invalid_dpop_proof |
nonce must match server’s nonce (if nonces are required) | 400 use_dpop_nonce (with DPoP-Nonce header) |
Code Example: Generating DPoP Proofs in TypeScript
The following example demonstrates the full DPoP flow using the Web Crypto API, suitable for browser and Node.js environments:
import { SignJWT, exportJWK, calculateJwkThumbprint } from 'jose'
// Step 1: Generate an ephemeral EC key pair
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
)
// Step 2: Export the public key as JWK (for the proof header)
const publicJwk = await exportJWK(keyPair.publicKey)
publicJwk.alg = 'ES256'
// Step 3: Create a DPoP proof for a token request
async function createDpopProof(
method: string,
url: string,
nonce?: string
): Promise<string> {
const builder = new SignJWT({
htm: method,
htu: url,
...(nonce ? { nonce } : {}),
})
.setProtectedHeader({
typ: 'dpop+jwt',
alg: 'ES256',
jwk: publicJwk,
})
.setJti(crypto.randomUUID())
.setIssuedAt()
return builder.sign(keyPair.privateKey)
}
// Step 4: Request a DPoP-bound token
const tokenUrl = 'https://auth.example.com/api/auth/token'
const dpopProof = await createDpopProof('POST', tokenUrl)
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': dpopProof,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'auth-code',
redirect_uri: 'https://app.example.com/callback',
client_id: 'your-client-id',
code_verifier: 'pkce-verifier',
}),
})
// Handle nonce requirement
if (response.status === 400) {
const nonce = response.headers.get('DPoP-Nonce')
if (nonce) {
// Retry with nonce
const retryProof = await createDpopProof('POST', tokenUrl, nonce)
// ... retry the request with retryProof
}
}
const { accessToken } = await response.json()
// Step 5: Use the DPoP-bound token on API calls
const apiUrl = 'https://api.example.com/users/me'
const apiProof = await createDpopProof('GET', apiUrl)
const apiResponse = await fetch(apiUrl, {
headers: {
'Authorization': `DPoP ${accessToken}`,
'DPoP': apiProof,
},
})Each DPoP proof must have a unique jti and a fresh iat. Never reuse proofs across requests. The htm and htu must exactly match the request — a proof created for GET /api/users cannot be used for POST /api/users or GET /api/roles.
Security Properties
DPoP provides the following security guarantees:
| Property | How It Is Achieved |
|---|---|
| Token binding | Access token’s cnf.jkt matches the proof’s jwk thumbprint |
| Replay protection | Each proof has a unique jti and recent iat; server-side nonces add extra freshness |
| Method/URL binding | htm and htu prevent proof reuse across different endpoints |
| Key non-exfiltration | Private key is generated with extractable: false in Web Crypto |
| Forward secrecy | Ephemeral key pairs mean compromise of one session’s key does not affect other sessions |
DPoP does not protect against:
- XSS with full code execution: If an attacker can execute arbitrary JavaScript in the same origin, they can call
crypto.subtle.sign()using the non-extractable key to create valid proofs. However, they cannot exfiltrate the key for use outside the browser. - Compromised client binary: If the client application itself is backdoored, DPoP offers no additional protection.
Related Concepts
- Tokens Explained — JWT structure, signing, and verification
- OAuth 2.0 & OIDC — The authorization framework DPoP extends
- M2M Client Credentials — DPoP-bound service tokens
- Applications — Enable DPoP per-application in the Console