OAuth

DPoP

I am indeed the holder of the private key for a provided public key.

Create a key pair

The private key signs the proof. The public key checks the signature.

const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  false, // Disable private key export.
  ["sign", "verify"],
);

const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);

Checking for a saved key…

Build the JWT

Edit the header and payload. Each new request needs a new proof ID.

FieldMeaning
typProof type
algSignature algorithm
jwkPublic key
FieldMeaning
jtiUnique ID
htmHTTP method
htuURL without query or fragment
iatUnix time in seconds
athAccess token hash; required when presenting an access token

Sign the proof

Encode the JSON as base64url, then sign the two parts with the private key.

base64url(header) . base64url(payload) . signature

Verify on the server

Send the proof in the DPoP header. The server checks the actual request.

Each tester keeps its own signed proof. The create buttons also fill steps 2 and 3 with a fresh, signed proof for that request.

1. Request a simulated access token

The issuer checks the proof, then signs a JWT with your key thumbprint in cnf.jkt. No ath is needed.

POST /api/dpop/token

DPoP: <signed proof>

2. Request a simulated protected resource

Present the token and a fresh proof. The resource checks the issuer signature, cnf.jkt, and ath.

GET /api/dpop/resource

Authorization: DPoP <access token>

DPoP: <signed proof with ath>

Try changing the token, removing ath in step 2, using a different key, or sending the same proof twice.

DPoP proof verification example
import { EmbeddedJWK, jwtVerify } from "jose";

const proof = request.headers.get("DPoP") ?? "";
// EmbeddedJWK reads the public key from the proof's jwk header.
const { payload, protectedHeader } = await jwtVerify(proof, EmbeddedJWK, {
  typ: "dpop+jwt",
  algorithms: ["ES256"],
  requiredClaims: ["jti", "htm", "htu", "iat"],
  maxTokenAge: "5m",
  clockTolerance: 30,
});

const url = new URL(request.url);
url.search = "";
url.hash = "";
if (payload.htm !== request.method || payload.htu !== url.href) {
  throw new Error("Proof does not match this request");
}
if (
  typeof payload.jti !== "string" ||
  !payload.jti ||
  !Number.isSafeInteger(payload.iat)
) {
  throw new Error("Invalid proof ID or creation time");
}

await rejectReplay(protectedHeader.jwk, payload.jti, payload.iat);

Example with jose. This excerpt checks the proof. A resource request also needs the access token, cnf.jkt, and ath checks. The request URL must be the public URL. The rejectReplay helper must atomically reject reused key thumbprints and proof IDs until the proof expires. Also check the nonce if the server issued one. See RFC 9449, section 4.3.

Verification limits

This demo accepts ES256 proofs up to five minutes old, with 30 seconds of clock allowance. Replay detection is best effort; its history can reset. No server nonce is required.

Demo access tokens last five minutes and can stop working when the demo resets. They grant access only to simulated data. A valid proof shows key possession; it does not identify a user.

Loading key…

This demo issues simulated access tokens and returns simulated data. It does not sign in users or grant access to real resources. See RFC 9449, section 4.