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:
- DPoP prevents token replay - a stolen token cannot be used by an attacker
- 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
jticlaims 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-Keyheader - 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
- DPoP - RFC 9449 sender-constrained tokens
- OAuth 2.1 for Agents - token validation
- Error Handling - full idempotency guide
- Orders and After Purchase - applying idempotent writes to order creation