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
- The client generates a public/private key pair (once, at startup or per session)
- When requesting a token, the client includes a DPoP proof JWT in the
DPoPheader. The proof contains the public key, the HTTP method and URL, and a unique identifier. - Auris validates the proof, binds the token to the public key (via a
jktclaim — JWK Thumbprint), and returns aDPoPtoken type instead ofBearer - On every API call, the client includes both the access token (
Authorization: DPoP <token>) and a fresh DPoP proof (DPoP: <proof>) - The resource server validates the proof against the
jktclaim 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:
| Setting | Description |
|---|---|
| Enable DPoP | Accept DPoP proofs. Tokens requested with DPoP will be sender-constrained. Tokens without DPoP are still accepted. |
| Require DPoP | Reject all token requests without a valid DPoP proof. Enable this only after all clients have migrated. |
| Require Nonces | Server-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:
- Enable DPoP (but do not require it) — clients can opt in
- Update all clients to send DPoP proofs
- Monitor that all token requests include DPoP proofs (check the Auris logs)
- 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:
- Client sends a token request with a DPoP proof (no nonce on first attempt)
- If a nonce is required, Auris responds with
HTTP 400and aDPoP-Nonceheader containing the nonce value - Client creates a new DPoP proof including the nonce and retries the request
- 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:
| Issue | Cause | Fix |
|---|---|---|
invalid_dpop_proof | Proof JWT is malformed or signature is invalid | Verify 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/URL | Ensure htm matches the HTTP method and htu matches the full URL (including scheme and host, excluding query/fragment) |
use_dpop_nonce | Server requires a nonce but none was provided | Read the DPoP-Nonce header from the response and include it in the next proof |
dpop_proof_replay | The same jti was used twice | Generate a unique jti (UUID) for every proof |
| Token rejected by resource server | The jkt in the token does not match the proof’s key | Ensure you use the same key pair for both the token request and API calls |
iat too old | Clock skew between client and server | Ensure 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
| Operation | Permission |
|---|---|
| Enable/configure DPoP on an application | manage:applications |
| Manage DPoP configuration | manage:dpop_config |
| Request tokens with DPoP | No special permission (client authentication only) |
Related Guides
- M2M Client Credentials — DPoP section for M2M tokens
- Hosted Login (PKCE) — DPoP can be combined with PKCE for maximum security
- Attack Protection — Other security layers that complement DPoP