Skip to Content
GuidesSecurityImplementing DPoP

Implementing DPoP

DPoP (Demonstrating Proof of Possession, RFC 9449) binds access tokens to a specific client’s cryptographic key pair. Unlike standard Bearer tokens that can be used by anyone who possesses them, DPoP-bound tokens are useless to an attacker who steals them — they cannot produce a valid proof without the private key.

Why use DPoP:

  • Prevent token theft: XSS attacks that exfiltrate tokens from localStorage or cookies cannot use the stolen tokens without the corresponding private key
  • Prevent log leakage: Tokens accidentally logged in server logs or error tracking systems cannot be replayed
  • Prevent replay attacks: Each DPoP proof includes the HTTP method and URL, binding it to a specific request
  • Compliance: Some security standards and financial APIs require sender-constrained tokens

How DPoP Works

  1. The client generates a public/private key pair (once, at startup or per session)
  2. When requesting a token, the client includes a DPoP proof JWT in the DPoP header. The proof contains the public key, the HTTP method and URL, and a unique identifier.
  3. Auris validates the proof, binds the token to the public key (via a jkt claim — JWK Thumbprint), and returns a DPoP token type instead of Bearer
  4. On every API call, the client includes both the access token (Authorization: DPoP <token>) and a fresh DPoP proof (DPoP: <proof>)
  5. The resource server validates the proof against the jkt claim in the token

Console Setup

Enable DPoP on the Application

In the Auris Console, go to Applications and select your application. Under the Settings tab, find the DPoP section:

SettingDescription
Enable DPoPAccept DPoP proofs. Tokens requested with DPoP will be sender-constrained. Tokens without DPoP are still accepted.
Require DPoPReject all token requests without a valid DPoP proof. Enable this only after all clients have migrated.
Require NoncesServer-issued nonces in DPoP proofs. Adds replay protection at the cost of an extra round-trip.

Plan Your Migration

If you have existing clients using Bearer tokens, use a gradual migration approach:

  1. Enable DPoP (but do not require it) — clients can opt in
  2. Update all clients to send DPoP proofs
  3. Monitor that all token requests include DPoP proofs (check the Auris logs)
  4. Enable “Require DPoP” to reject requests without proofs

Implementation for SPAs

Generate a Key Pair

Generate an ECDSA P-256 key pair using the Web Crypto API. Do this once per browser session and store the key pair in memory (not localStorage — it is non-extractable by design):

const dpopKeyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, // non-extractable — the private key cannot be exported ['sign', 'verify'], )

Create a DPoP Proof

A DPoP proof is a JWT signed with the private key. It contains the public key (as jwk in the header), the target HTTP method and URL, a unique jti, and the current timestamp:

async function createDpopProof(keyPair, method, url, nonce) { // Export the public key as JWK const publicKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) // Create the proof header const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y, }, } // Create the proof payload const payload = { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), ...(nonce && { nonce }), } // Sign the proof (helper function to create a compact JWT) return await signJwt(header, payload, keyPair.privateKey) }

Request a Token with DPoP

Include the DPoP proof in the DPoP header when requesting a token:

const tokenUrl = 'https://auth.yourdomain.com/api/auth/token' const proof = await createDpopProof(dpopKeyPair, 'POST', tokenUrl) const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': proof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authorizationCode, redirect_uri: 'https://your-app.com/callback', client_id: 'your-client-id', code_verifier: pkceCodeVerifier, }), }) const token = await response.json() // token.token_type will be "DPoP" instead of "Bearer"

Make API Calls with DPoP

Every API call must include both the DPoP-bound token and a fresh proof:

async function dpopFetch(url, method, dpopKeyPair, accessToken, options = {}) { const proof = await createDpopProof(dpopKeyPair, method, url) return fetch(url, { ...options, method, headers: { ...options.headers, 'Authorization': `DPoP ${accessToken}`, 'DPoP': proof, }, }) } // Usage const users = await dpopFetch( 'https://auth.yourdomain.com/api/users', 'GET', dpopKeyPair, token.access_token, )

Implementation for Node.js

For server-side Node.js applications, use the jose library for key generation and JWT signing:

import * as jose from 'jose' // Generate a key pair at service startup const { publicKey, privateKey } = await jose.generateKeyPair('ES256') async function createDpopProof(method: string, url: string, nonce?: string) { const publicJwk = await jose.exportJWK(publicKey) const proof = await new jose.SignJWT({ htm: method, htu: url, ...(nonce && { nonce }), }) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk, }) .setJti(crypto.randomUUID()) .setIssuedAt() .sign(privateKey) return proof } // Request a token with DPoP const tokenUrl = 'https://auth.yourdomain.com/api/auth/token' const proof = await createDpopProof('POST', tokenUrl) const tokenResponse = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': proof, }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.AURIS_CLIENT_ID, client_secret: process.env.AURIS_CLIENT_SECRET, }), })

SDK Support

The @auris/js SDK includes a createDpopProof helper that handles key generation, proof creation, and nonce management:

import { AurisClient, createDpopProof } from '@auris/js' // The SDK can generate and manage DPoP keys automatically const auris = new AurisClient({ domain: 'auth.yourdomain.com', clientId: 'your-client-id', useDpop: true, // Enables automatic DPoP proof generation }) // loginWithRedirect() and handleRedirectCallback() will // automatically include DPoP proofs in token requests await auris.loginWithRedirect({ scope: 'openid profile' })

Nonce Handling

When “Require Nonces” is enabled on the application, Auris issues a server-side nonce that must be included in the DPoP proof. This provides replay protection — each proof can only be used once.

The flow for nonce handling:

  1. Client sends a token request with a DPoP proof (no nonce on first attempt)
  2. If a nonce is required, Auris responds with HTTP 400 and a DPoP-Nonce header containing the nonce value
  3. Client creates a new DPoP proof including the nonce and retries the request
  4. Auris accepts the proof and returns the token
async function requestTokenWithNonce(dpopKeyPair, tokenUrl, body) { let nonce = undefined for (let attempt = 0; attempt < 2; attempt++) { const proof = await createDpopProof(dpopKeyPair, 'POST', tokenUrl, nonce) const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'DPoP': proof, }, body: new URLSearchParams(body), }) // Check if a nonce is required const newNonce = response.headers.get('DPoP-Nonce') if (response.status === 400 && newNonce) { nonce = newNonce continue // retry with nonce } return await response.json() } throw new Error('Failed to obtain token after nonce retry') }

Always check for a DPoP-Nonce header on every response — not just error responses. The server may rotate the nonce on successful responses as well, and you should use the latest nonce on subsequent requests.


Troubleshooting

Common issues when implementing DPoP:

IssueCauseFix
invalid_dpop_proofProof JWT is malformed or signature is invalidVerify the proof is a valid JWT signed with the same key pair
invalid_dpop_proof (htm/htu mismatch)The htm or htu in the proof does not match the actual request method/URLEnsure htm matches the HTTP method and htu matches the full URL (including scheme and host, excluding query/fragment)
use_dpop_nonceServer requires a nonce but none was providedRead the DPoP-Nonce header from the response and include it in the next proof
dpop_proof_replayThe same jti was used twiceGenerate a unique jti (UUID) for every proof
Token rejected by resource serverThe jkt in the token does not match the proof’s keyEnsure you use the same key pair for both the token request and API calls
iat too oldClock skew between client and serverEnsure the client’s clock is accurate. Auris allows up to 60 seconds of skew.

Testing with curl

DPoP is difficult to test with curl directly because each request requires a unique signed JWT proof. For testing purposes, you can generate proofs with a script:

# Generate a DPoP proof with Node.js and pipe it to curl PROOF=$(node -e " const jose = require('jose'); (async () => { const { privateKey, publicKey } = await jose.generateKeyPair('ES256'); const jwk = await jose.exportJWK(publicKey); const proof = await new jose.SignJWT({ htm: 'POST', htu: 'https://auth.yourdomain.com/api/auth/token' }) .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk }) .setJti(require('crypto').randomUUID()) .setIssuedAt() .sign(privateKey); process.stdout.write(proof); })(); ") curl -X POST https://auth.yourdomain.com/api/auth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "DPoP: $PROOF" \ -d "grant_type=client_credentials" \ -d "client_id=your-client-id" \ -d "client_secret=your-client-secret"

DPoP Token Format

When DPoP is used, the issued access token includes a jkt claim (JWK Thumbprint) that ties it to the client’s public key:

{ "sub": "user-id", "iss": "https://auth.yourdomain.com", "aud": "https://auth.yourdomain.com", "exp": 1735000000, "iat": 1734996400, "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I", "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" } }

The token_type in the token response will be "DPoP" instead of "Bearer", indicating that the token must be presented with a DPoP proof.


Required Permissions

OperationPermission
Enable/configure DPoP on an applicationmanage:applications
Manage DPoP configurationmanage:dpop_config
Request tokens with DPoPNo special permission (client authentication only)