Agent Surface

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.jkt claim: 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 URI
  • iat (Issued At): Unix timestamp
  • jti (JWT ID): unique nonce, prevents replay of the same proof

The client sends both the Authorization (Bearer token) and DPoP headers. The server validates:

  1. The DPoP proof is a valid JWT signed by a key the client registered
  2. The htm and htu match the current request
  3. The jti has never been seen before (prevents reuse)
  4. The iat is 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:

  1. The DPoP proof is valid (signature, htm, htu, jti)
  2. The DPoP proof's JWK thumbprint matches the token's cnf.jkt claim

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 DPoP proof with jti, htm, htu, iat
  • Server validates DPoP signature, claims, and JTI uniqueness
  • Access tokens include a cnf.jkt claim 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

On this page