Agent Surface

Authentication

Idempotency and Replay Protection

Nonce stores, DPoP jti uniqueness, preventing double-submission

Last verified 2026-09-25

Summary

Idempotency and replay protection operate at different layers. jti uniqueness prevents a stolen proof from being replayed. Idempotency-Key headers prevent double-submission of write operations if retried due to network failure. Together they prevent both replay and double-submission for high-value operations like payments. This page covers how the two compose at the authentication layer; the full idempotency-key implementation lives on Idempotency.

  • DPoP jti: unique per request, stored with 2-minute TTL, prevents proof replay
  • Idempotency-Key: client-generated, sent on every retry, server deduplicates
  • Idempotency store: cache responses (success and failure) for 24 hours, keyed by the client's
  • Use Redis for distributed systems; in-memory for single instance
  • Combine both for payments, user provisioning, data deletion

Two distinct security concerns: idempotency (the same request can be safely retried without side effects) and replay protection (a captured or proof cannot be used twice). DPoP's jti ( ID) claim and idempotency keys solve these in different layers.

Replay Protection via DPoP JTI

RFC 9449 DPoP proofs include a jti claim - a unique identifier per request. The server must never see the same jti twice. If an attacker captures a DPoP proof and attempts to reuse it, the server rejects the duplicate jti.

{
  "jti": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "htm": "POST",
  "htu": "https://api.example.com/payments",
  "iat": 1700000000,
  "exp": 1700000120
}

The server maintains a store of seen JTIs, valid for the lifetime of the proof (typically 2 minutes). On every DPoP proof validation:

const seenJtis = new Map<string, number>();
const JTI_MAX_AGE = 2 * 60; // 2 minutes

async function validateDPopProof(proof: string): Promise<void> {
  const { payload } = await jwtVerify(proof, publicKey);

  const jti = payload.jti as string;
  const now = Math.floor(Date.now() / 1000);

  // Check if this JTI has been seen
  if (seenJtis.has(jti)) {
    throw new Error("DPoP JTI has been replayed");
  }

  // Store the JTI with expiry
  seenJtis.set(jti, now + JTI_MAX_AGE);

  // Clean up expired JTIs
  for (const [id, expiry] of seenJtis.entries()) {
    if (expiry < now) {
      seenJtis.delete(id);
    }
  }
}

For distributed systems, use Redis or a similar distributed store:

import redis from "redis";

const redisClient = redis.createClient();
const JTI_TTL = 2 * 60; // 2 minutes

async function recordDPopJti(jti: string): Promise<boolean> {
  const result = await redisClient.set(`dpop:jti:${jti}`, "1", {
    EX: JTI_TTL, // Auto-expire after TTL
    NX: true, // Only set if key doesn't exist
  });

  return result === "OK";
}

async function validateDPopProof(proof: string): Promise<void> {
  const { payload } = await jwtVerify(proof, publicKey);
  const jti = payload.jti as string;

  const recorded = await recordDPopJti(jti);
  if (!recorded) {
    throw new Error("DPoP JTI has been replayed");
  }
}

The Redis NX flag ensures the JTI is only set once. If the key already exists, the operation fails, signaling a replay.

Idempotency Keys for Mutation Operations

Idempotency keys are client-generated identifiers that prevent double-submission of write operations. If a request is retried due to network failure, timeout, or connection loss, the server recognizes the same logical operation and returns the cached response instead of executing twice. The client sends an Idempotency-Key header alongside its Authorization and DPoP headers; the server caches the response - success or failure - for a window, typically 24 hours, keyed by that header. Generate keys as UUIDv7 so the embedded timestamp lets old keys expire naturally, and log them for audit trails.

The full key-generation, storage, and TTL mechanism - including the production Redis handler - lives on the Idempotency page. What matters at the authentication layer is how this mechanism composes with DPoP replay protection.

Combining DPoP and Idempotency

For high-value operations (payments, user provisioning, data deletion), use both, and validate DPoP before touching the idempotency store:

  1. DPoP prevents token replay - a stolen token cannot be used by an attacker
  2. Idempotency-Key prevents double-submission - if the agent retries due to network failure, the operation executes only once
async function handlePayment(req: Request): Promise<Response> {
  const idempotencyKey = req.headers.get("Idempotency-Key");
  const dpopHeader = req.headers.get("DPoP");

  // Validate DPoP first (prevents token replay)
  if (dpopHeader) {
    await validateDPopProof(dpopHeader, req.method, req.url);
  }

  if (!idempotencyKey) {
    return Response.json({ error: "Idempotency-Key is required" }, { status: 400 });
  }

  // Idempotency lookup, cache, and TTL: see /docs/error-handling/idempotency
  return handleIdempotent(idempotencyKey, () => processPayment(await req.json()));
}

Maintain separate stores with separate TTLs for the two nonces: a DPoP JTI store (2-minute TTL, since a proof is only valid briefly) and an idempotency key store (24-hour TTL, since a client may retry hours later). Reusing one store for both loses the distinction between "this proof was replayed" and "this operation was retried."

Checklist

  • DPoP jti claims are stored and validated for uniqueness
  • DPoP JTI store auto-expires after 2 minutes, kept separate from the idempotency key store
  • Mutation operations require and validate Idempotency-Key header
  • Idempotency responses (success and failure) are cached for 24 hours; see Idempotency for the store implementation
  • Distributed systems use Redis or equivalent for both nonce stores
  • DPoP validation runs before the idempotency lookup on every request
  • Agents generate UUIDv7 idempotency keys and reuse the same key across retries of one logical operation

See Also

On this page