DPoP nonces + per-jkt jti replay tables
The shape
DPoP carries two distinct concerns; keep them separate:
- DPoP nonces (RFC 9449 §8.2): server-issued anti-pre-generation tokens. Required
on token-issuance endpoints (
/oauth/par,/oauth/token). The AS returns401 use_dpop_noncewithDPoP-Nonceheader on first request without a fresh nonce; the agent retries with the nonce in the DPoP proof'snonceclaim. Stored in Redis atoid4ac:nonce:<id>TTL 90s. - Per-jkt jti replay defence (RFC 9449 §11.1): prevents replay of an already-seen
DPoP proof. Keyed on
(jkt, jti). Stored in Redis set atoid4ac:dpop_jti:<jkt>with TTL = 60s (iat skew window).
Other DPoP rules:
iatwindow: ±60s from server clock.htu: normalise (lowercase scheme + host, strip default ports, strip fragment + query, KEEP path).htm: exact-match request method.
DPoP proof header
{
"typ": "dpop+jwt",
"alg": "EdDSA",
"jwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }
}DPoP proof body
{
"jti": "01HJ9XK0YN0K6V6S8Y8E5P5W6Y",
"htm": "POST",
"htu": "https://as.oid4pay.com/oauth/par",
"iat": 1747260300,
"nonce": "<server-issued nonce>",
"ath": "<b64url SHA-256 of the access token, when calling an RS>"
}Nonce dance
1. POST /oauth/par (no nonce)
401 Unauthorized
DPoP-Nonce: a8c2-...
2. POST /oauth/par with DPoP proof carrying nonce="a8c2-..."
201 Created
DPoP-Nonce: 9b4e-... (server rotates the nonce)
3. POST /oauth/token with DPoP proof carrying nonce="9b4e-..."
200 OKResource server checks
On an RS request (merchant /charge, AS /introspect, etc.) the DPoP proof MUST carry the ath (access token hash) claim. The RS validates:
htmmatches request method.htumatches request URL after normalisation.iatwithin ±60s.jtinot seen for thisjktwithin the last 60s.jwkthumbprint matches the JWT-AT'scnf.jkt.athmatches b64url SHA-256 of the access token.- Header
algin{EdDSA, ES256}. Reject HMAC.
Worked example
// Node: generate a DPoP proof for a charge call.
import { SignJWT, calculateJwkThumbprint } from "jose";
import { createHash } from "node:crypto";
async function makeDpop(method, url, accessToken, privateKey, publicJwk, nonce) {
const ath = createHash("sha256").update(accessToken).digest("base64url");
return await new SignJWT({
htm: method,
htu: url,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
ath,
nonce,
})
.setProtectedHeader({ alg: "EdDSA", typ: "dpop+jwt", jwk: publicJwk })
.sign(privateKey);
}