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);
Build the JWT
Edit the header and payload. Each new request needs a new proof ID.
| Field | Meaning |
|---|---|
typ | Proof type |
alg | Signature algorithm |
jwk | Public key |
| Field | Meaning |
|---|---|
jti | Unique ID |
htm | HTTP method |
htu | URL without query or fragment |
iat | Unix time in seconds |
ath | Access 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.
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.