---
title: "Idempotency and Replay Protection"
description: "Nonce stores, DPoP jti uniqueness, preventing double-submission"
url: "https://agentsurface.dev/docs/authentication/idempotency-and-replay"
lastVerified: 2026-09-25
lastModified: 2026-09-25T05:46:01.000Z
---



## Summary [#summary]

Idempotency and replay protection operate at different layers. DPoP 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](/docs/error-handling/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 idempotency key
* 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 token or proof cannot be used twice). DPoP's `jti` (JWT ID) claim and idempotency keys solve these in different layers.

## Replay Protection via DPoP JTI [#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`.

```json
{
  "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:

```typescript
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:

```typescript
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-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](/docs/error-handling/idempotency) page. What matters at the authentication layer is how this mechanism composes with DPoP replay protection.

## Combining DPoP and Idempotency [#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

```typescript
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 [#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](/docs/error-handling/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 [#see-also]

* [DPoP](/docs/authentication/dpop) - RFC 9449 sender-constrained tokens
* [OAuth 2.1 for Agents](/docs/authentication/oauth-for-agents) - token validation
* [Error Handling](/docs/error-handling/idempotency) - full idempotency guide
* [Orders and After Purchase](/docs/agentic-commerce/orders-and-after-purchase) - applying idempotent writes to order creation
