Skip to main content

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 returns 401 use_dpop_nonce with DPoP-Nonce header on first request without a fresh nonce; the agent retries with the nonce in the DPoP proof's nonce claim. Stored in Redis at oid4ac: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 at oid4ac:dpop_jti:<jkt> with TTL = 60s (iat skew window).

Other DPoP rules:

  • iat window: ±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 OK

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

  1. htm matches request method.
  2. htu matches request URL after normalisation.
  3. iat within ±60s.
  4. jti not seen for this jkt within the last 60s.
  5. jwk thumbprint matches the JWT-AT's cnf.jkt.
  6. ath matches b64url SHA-256 of the access token.
  7. Header alg in {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);
}