DPoP - sender-constrained tokens
WIPRFC 9449. Bind every JWT or bearer to a client public key so a stolen token is useless without the private key.
What is DPoP?
DPoP (Demonstration of Proof-of-Possession, RFC 9449) binds an access token to a client public key. Every request carries:
- The access token (JWT or opaque bearer).
- A
DPoPheader containing a signed proof that the client holds the matching private key.
A stolen token is useless without the private key. DPoP is the closest thing to mTLS for browser SPAs and mobile apps.
When to use
- High-risk APIs (banking, healthcare, admin consoles) where token theft is a credible threat.
- OIDC RP posture where downstream services need cryptographic binding.
- Any deployment where you want a stronger guarantee than
securecookies provide.
Constructor
import {
authComputeJwkThumbprint,
DPoPVerifier,
MemoryDPoPNonceStore,
} from '@gentleduck/auth/core/transport'
const verifier = new DPoPVerifier({
publicKey: clientJwk, // the client's published JWK
expectedAudience: 'https://api.example.com', // aud claim to verify
nonces: new MemoryDPoPNonceStore({ ttlMs: 10 * 60 * 1000 }),
})
For production, swap to AuthRedisDPoPNonceStore so jti replay detection
spans replicas.
Verifying a request
import { AuthError } from '@gentleduck/auth/core'
app.get('/api/secret', async (req, res) => {
const ctx = await auth.resolveSession(req)
if (!ctx) throw new AuthError('AUTH/UNAUTHENTICATED')
const dpopHeader = req.headers.get('dpop')
if (!dpopHeader) throw new AuthError('AUTH/DPOP_INVALID')
await verifier.verify(ctx.session.jwt, dpopHeader)
// jti replay -> AUTH/DPOP_INVALID
// thumbprint mismatch -> AUTH/DPOP_INVALID
// bad signature -> AUTH/DPOP_INVALID
res.json({ ok: true })
})
How it works
- Client generates an EC P-256 keypair (one per device, persisted in the OS keychain or Web Crypto SubtleCrypto).
- On each request, the client signs a tiny DPoP JWT containing:
htm- HTTP method.htu- HTTP URL.jti- random nonce (each one unique).iat- timestamp.ath(if attaching to an access token) - SHA-256 of the token.
- Sends the access token plus the DPoP proof as a
DPoPheader. - Server:
verifier.verify()checks signature,htm,htu,jti(against the nonce store),ath, and the thumbprint in the access token'scnf.jktclaim.
If any check fails: AUTH/DPOP_INVALID.
Binding a payload to DPoP
For internal services that mint short-lived JWTs and want to bind them to the DPoP key:
import { authBindPayloadToDPoP } from '@gentleduck/auth/core/transport'
const payload = {
sub: 'identity-id',
iss: 'https://api.example.com',
exp: Math.floor(Date.now() / 1000) + 60,
}
const bound = authBindPayloadToDPoP(payload, dpopHeader)
// bound = { ...payload, cnf: { jkt: '<thumbprint>' } }
const jwt = await transport.signJwt(bound)
The downstream service receives the JWT, verifies the signature, and
checks cnf.jkt against the DPoP proof on its own - the binding rides
along with the token.
Computing thumbprints
For custom flows that need to compute the RFC 7638 thumbprint of a JWK:
import { authComputeJwkThumbprint } from '@gentleduck/auth/core/transport'
const jkt = await authComputeJwkThumbprint(clientJwk)
// -> 'kIQ-AzaP-...' (43-character base64url)
This is what goes into the JWT's cnf.jkt claim.
Memory vs Redis nonce store
The nonce store is the jti replay guard. Every accepted DPoP proof
records its jti; a duplicate jti is rejected.
| Store | Use case |
|---|---|
MemoryDPoPNonceStore | Dev / test. Per-process; replicas have independent stores. |
AuthRedisDPoPNonceStore(client, { ttlMs }) | Production. Shared across all replicas. |
import Redis from 'ioredis'
import { AuthRedisDPoPNonceStore } from '@gentleduck/auth/adapters/redis'
const verifier = new DPoPVerifier({
publicKey,
nonces: new AuthRedisDPoPNonceStore(new Redis(process.env.REDIS_URL!), {
ttlMs: 10 * 60 * 1000,
}),
})
Threat model
DPoP defends against:
- Token theft from logs / network captures - the token alone is useless.
- XSS exfiltration - the private key lives in non-extractable Web Crypto storage; even an XSS that reads the access token cannot extract the key.
- Replay attacks -
jtiensures every proof is unique. - Cross-API token misuse -
audandhtuclaims scope every proof to a specific endpoint.
DPoP does not defend against:
- A compromised device (an attacker who can use the private key wins).
- Phishing (the legitimate client signs the proof for the attacker's request).