Skip to Content
ConceptsDPoP (Proof of Possession)

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:

AttackMechanism
Token exfiltration via XSSMalicious JavaScript reads the token from localStorage and sends it to an attacker’s server
Token leakage via logsAccess tokens accidentally logged by proxies, CDNs, or application servers
Token replayAn intercepted token is replayed from a different device or IP
Token theft via compromised middlewareA 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:

  1. The client generates an ephemeral key pair (RSA or EC)
  2. When requesting a token, the client creates a DPoP proof — a JWT signed with the private key
  3. The authorization server (Auris) extracts the public key from the proof and binds the issued token to that key via a JWK thumbprint (jkt claim)
  4. On every API call, the client sends both the bound access token and a fresh DPoP proof signed with the same private key
  5. The resource server verifies that the proof’s public key matches the token’s jkt binding

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" }
ClaimDescription
jtiA unique identifier for this proof (prevents replay)
htmThe HTTP method of the request this proof accompanies
htuThe HTTP URL of the request (without query/fragment)
iatWhen the proof was created (must be recent)
nonceServer-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-verifier

Step 4: Server Binds the Token to the Key

Auris validates the DPoP proof:

  1. Verifies the typ header is dpop+jwt
  2. Verifies the signature using the embedded jwk public key
  3. Checks that htm matches the request method and htu matches the request URL
  4. Checks that iat is recent (within acceptable clock skew)
  5. Checks jti uniqueness (prevents proof replay)
  6. If a nonce was required, validates the nonce claim

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:

  1. Extracts the DPoP proof from the DPoP header
  2. Validates the proof (signature, htm, htu, iat, jti)
  3. Computes the JWK thumbprint of the proof’s jwk public key
  4. Compares it to the cnf.jkt claim in the access token
  5. 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

  1. Client sends a token request without a nonce
  2. Server responds with 400 and a DPoP-Nonce header containing a fresh nonce value
  3. Client retries the request, including the nonce in the DPoP proof’s nonce claim
  4. Server validates the nonce and processes the request
  5. Optionally, the server includes a new DPoP-Nonce header 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:

DimensionDPoP (RFC 9449)mTLS (RFC 8705)
Binding mechanismJWK thumbprint in token + signed proof JWTClient certificate thumbprint in token (cnf.x5t#S256)
Infrastructure requirementNone (pure application-layer)TLS termination must preserve client cert, or use certificate-bound tokens
Client complexityGenerate key pair + sign JWTsObtain and manage X.509 certificates
Browser supportWorks in browsers via Web Crypto APINot supported in browsers (no client cert API)
Certificate managementNo certificates neededRequires PKI or certificate authority
Proxy/CDN compatibilityExcellent (headers pass through)Problematic (TLS termination may strip client cert)
PerformanceProof generation is fast (EC signing is ~1ms)TLS handshake is heavier but amortized
Token theft protectionExcellentExcellent

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:

SettingDescription
Enable DPoPMaster toggle for DPoP support on this application
Require DPoPWhen enabled, the application rejects non-DPoP token requests
Require NoncesWhen 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:users

The 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:

CheckFailure Response
typ header must be dpop+jwt400 invalid_dpop_proof
jwk header must contain a valid public key400 invalid_dpop_proof
Signature must be valid using the embedded jwk400 invalid_dpop_proof
htm must match the current request’s HTTP method400 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 time400 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:

PropertyHow It Is Achieved
Token bindingAccess token’s cnf.jkt matches the proof’s jwk thumbprint
Replay protectionEach proof has a unique jti and recent iat; server-side nonces add extra freshness
Method/URL bindinghtm and htu prevent proof reuse across different endpoints
Key non-exfiltrationPrivate key is generated with extractable: false in Web Crypto
Forward secrecyEphemeral 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.