Authentication
DPoP: Demonstrating Proof-of-Possession
RFC 9449 sender-constrained tokens, preventing token replay attacks
Last verified 2026-09-24
Summary
DPoP binds an access to a client's public key. On every request, the client signs a proof with the request method, URI, and a unique jti (nonce). If the token is stolen without the private key, an attacker cannot create a valid proof, making the token useless.
Client: (with private key) Server: (verifies)
Generate keypair Extract public key from DPoP
Sign DPoP proof Verify signature
Send token + DPoP Check htm, htu, jti- Each request includes a DPoP proof: unique jti, htm (method), htu (URI), iat (timestamp)
- Server validates signature, claim values, and jti uniqueness (prevents replay)
- Token includes a
cnf.jktclaim: thumbprint of the DPoP proof's JWK - Required for financial operations, user data access, administrative actions
- Use ES256 or Ed25519 for DPoP proof signing
When an access token is stolen - via a log, a memory dump, a man-in-the-middle attack - the attacker can replay it against the resource server until it expires. Demonstrating Proof-of-Possession (DPoP, RFC 9449) solves this by binding the token to the client's public key. If the token is stolen, it cannot be replayed because the attacker lacks the private key.
Use DPoP for high-value operations and agent-to-agent communication where token compromise is a real risk.
How DPoP Works
The client generates an Ed25519 or ES256 keypair. On every request, the client creates a DPoP proof JWT that includes:
htm(HTTP Method): the request method (GET, POST, etc.)htu(HTTP URI): the request URIiat(Issued At): Unix timestampjti(JWT ID): unique nonce, prevents replay of the same proof
The client sends both the Authorization (Bearer token) and DPoP headers. The server validates:
- The DPoP proof is a valid JWT signed by a key the client registered
- The
htmandhtumatch the current request - The
jtihas never been seen before (prevents reuse) - The
iatis recent (prevents old proofs from being replayed)
If the token is stolen without the private key, an attacker cannot create a valid DPoP proof for a different URI, so the stolen token is useless.
Client-Side: Generating DPoP Proofs
import { SignJWT, generateKeyPair } from "jose";
import { randomUUID } from "crypto";
// Generate keypair once and store securely
const { publicKey, privateKey } = await generateKeyPair("ES256");
async function generateDPopProof(method: string, uri: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const proof = await new SignJWT({
jti: randomUUID(), // Unique per request
htm: method, // GET, POST, etc.
htu: uri, // Full request URI
iat: now,
exp: now + 120, // 2 minutes validity
})
.setProtectedHeader({
alg: "ES256",
typ: "dpop+jwt",
jwk: {
// Public key in JWK format (without private component)
kty: "EC",
crv: "P-256",
x: publicKey.x,
y: publicKey.y,
},
})
.sign(privateKey);
return proof;
}
// On every request
const method = "GET";
const uri = "https://api.example.com/invoices";
const dpopProof = await generateDPopProof(method, uri);
const res = await fetch(uri, {
method,
headers: {
Authorization: `DPoP ${accessToken}`,
DPoP: dpopProof,
},
});The jti must be globally unique per request to prevent replay. Use a UUID or a counter.
Server-Side: Validating DPoP Proofs
import { jwtVerify, decodeProtectedHeader, importJWK } from "jose";
// Store of seen JTIs to prevent replay
const seenJtis = new Set<string>();
const JTI_MAX_AGE = 2 * 60; // 2 minutes
async function validateDPopProof(
dpopHeader: string,
method: string,
uri: string,
): Promise<{ thumbprint: string; sub: string }> {
// Read the header first, without verifying: the proof's own public key
// is embedded in it, so there is nothing to verify against yet.
const protectedHeader = decodeProtectedHeader(dpopHeader);
if (!protectedHeader.jwk) {
throw new Error("DPoP JWT missing public key in header");
}
const publicKey = await importJWK(protectedHeader.jwk, protectedHeader.alg);
// Now verify the signature
const { payload } = await jwtVerify(dpopHeader, publicKey, {
algorithms: ["ES256", "EdDSA"],
});
// Verify claims
const now = Math.floor(Date.now() / 1000);
if ((payload.iat as number) > now + 60) {
throw new Error("DPoP proof issued in the future");
}
if ((payload.iat as number) < now - 300) {
throw new Error("DPoP proof is stale");
}
if (payload.htm !== method) {
throw new Error(`DPoP htm does not match request method: ${payload.htm} vs ${method}`);
}
if (payload.htu !== uri) {
throw new Error(`DPoP htu does not match request URI: ${payload.htu} vs ${uri}`);
}
// Check JTI for replay
const jti = payload.jti as string;
if (seenJtis.has(jti)) {
throw new Error("DPoP JTI has been replayed");
}
seenJtis.add(jti);
// Clean up old JTIs (optional, in production use Redis or similar)
// ...
// Return the thumbprint for binding to access token
const thumbprint = await calculateJwkThumbprint(protectedHeader.jwk);
return { thumbprint, sub: payload.sub as string };
}Binding DPoP to Access Tokens
When issuing an access token, the authorization server includes the DPoP proof's JWK thumbprint in the token's cnf.jkt claim (RFC 9449 §6.1; dpop_jkt is the parameter the client sends in the authorization request, not a token claim):
{
"sub": "client_01HV3K8MNP",
"aud": "https://api.example.com/",
"scope": "invoices:read",
"cnf": {
"jkt": "fUHyO2r...rg"
},
"iat": 1700000000,
"exp": 1700003600
}On every request, the server verifies:
- The DPoP proof is valid (signature,
htm,htu,jti) - The DPoP proof's JWK thumbprint matches the token's
cnf.jktclaim
If an attacker steals the token but not the private key, they cannot create a valid DPoP proof with the matching thumbprint.
import { jwtVerify, calculateJwkThumbprint, decodeProtectedHeader, importJWK } from "jose";
async function validateTokenAndDPoP(
token: string,
dpopHeader: string,
method: string,
uri: string,
) {
// Validate the JWT token
const { payload: tokenPayload } = await jwtVerify(token, jwks);
// Validate the DPoP proof: read its embedded key, then verify against it
const protectedHeader = decodeProtectedHeader(dpopHeader);
const publicKey = await importJWK(protectedHeader.jwk!, protectedHeader.alg);
const { payload: dpopPayload } = await jwtVerify(dpopHeader, publicKey, {
algorithms: ["ES256", "EdDSA"],
});
// Compare thumbprints
const dpopThumbprint = await calculateJwkThumbprint(protectedHeader.jwk!);
const cnf = tokenPayload.cnf as { jkt?: string } | undefined;
if (dpopThumbprint !== cnf?.jkt) {
throw new Error("DPoP thumbprint does not match token");
}
// Verify htm and htu match
if (dpopPayload.htm !== method || dpopPayload.htu !== uri) {
throw new Error("DPoP proof does not match request");
}
return tokenPayload;
}When to Require DPoP
DPoP has overhead: every request requires generating and validating a proof. Use it when token compromise is a serious risk:
- Agent-to-agent communication where tokens flow through multiple systems
- Financial operations (payments, refunds, transfers)
- User data access (PII, medical records)
- Administrative operations (user creation, permission changes)
For read-only operations on public data, the overhead may not be justified.
MCP and DPoP
DPoP is not part of the ratified specification. The current 2026-07-28 revision's authorization and security-considerations pages make no mention of it. A DPoP profile for MCP exists only as an open, unmerged proposal (SEP-1932) - do not describe DPoP as an MCP requirement or recommendation until that lands.
None of this stops an MCP server from adopting DPoP on its own: DPoP is an OAuth-layer mechanism, and an MCP server is still an resource server underneath. A server that wants sender-constrained tokens for sensitive operations can require DPoP proofs independent of anything MCP-specific:
// MCP server validating DPoP
server.setRequestHandler(
{
schema: jsonrpc.JsonRpcRequestSchema,
},
async (request) => {
const dpopHeader = request.headers?.["DPoP"] as string;
const authHeader = request.headers?.["Authorization"] as string;
if (!dpopHeader) {
throw new Error("DPoP proof required");
}
await validateDPopProof(dpopHeader, request.method, request.uri);
// ... proceed with request
},
);See Protected Resource Metadata for advertising DPoP requirements.
Two 2026-07-28 MCP changes are not DPoP-specific, but they make a server-side DPoP
implementation easier to reason about if you add one. Sessions are removed, so there is no
connect-time authentication step whose proof a later request could ride on - every request already
carries its own token, which is the same per-request model DPoP proofs assume. And client
credentials must now be keyed by issuing authorization server (SEP-2352), so a cnf.jkt
thumbprint you choose to validate is unambiguously tied to one issuer's key material rather than
to a client identity that might have been registered in several places.
Checklist
- Clients generate Ed25519 or ES256 keypairs at startup
- Every request includes a unique
DPoPproof withjti,htm,htu,iat - Server validates DPoP signature, claims, and JTI uniqueness
- Access tokens include a
cnf.jktclaim with the proof's JWK thumbprint - Server validates that DPoP thumbprint matches token claim
- JTI store prevents reuse (Redis or equivalent for distributed systems)
- DPoP is required for sensitive operations (financial, user data, admin)
See Also
- OAuth 2.1 for Agents - token issuance
- Protected Resource Metadata - advertising DPoP requirements
- Idempotency and Replay Protection - nonce stores and DPoP
jti - RFC 9449: Demonstrating Proof-of-Possession Mechanisms - full specification
- SEP-1932: DPoP Profile for MCP - open, unmerged proposal to standardize this at the MCP layer
- jose on npm - JWT signing and validation