All articles

BLOG / IDEMPOTENCY KEY

Idempotency Keys vs Replay Protection: A Tested Example

Learn what idempotency keys protect, run a tested Node.js example, and distinguish safe retries from replay protection and one-use authorization.

Duplicate requests converge on one stored operation and a shared result-return path.Duplicate requests converge on one stored operation and a shared result-return path.

An idempotency key is a caller-generated identifier that lets a server recognize retries of the same business operation. The server associates the key with the authenticated owner, action, validated parameters, and an operation result. Reusing that identifier should recover or reconcile the operation rather than repeat its intended effect. A key is not permission to perform the action, and a different key can identify a different operation.

A checkout request succeeds, but its response never reaches the browser. The customer retries. Your application should return the existing order rather than create another one. Now consider a captured authorization credential submitted again to obtain another order. Your application should reject that reused authority. These situations can contain identical bytes and still require different decisions.

Idempotency controls repeated effects. Replay protection controls reused authority. This lab shows the difference with a small Node.js program. It makes no network requests and needs no packages. It is an educational model, not a MANDATE integration or a production security implementation.

Two questions your backend must answer

RFC 9110 defines HTTP idempotency around the intended effect of repeating an identical request. An application can also implement retry semantics for operations such as order creation using an idempotency key.

Control Question Typical repeated request
Idempotency Has this business operation already run? Return or reconcile its existing result.
Replay protection May this authorization evidence be used now? Reject authority already consumed.

An idempotency key supplied by a caller identifies an operation; possession of the key does not establish permission. A caller can submit a different key. That is why deduplication alone cannot decide whether another operation is authorized. Conversely, rejecting a consumed credential does not tell a customer whether their earlier order succeeded.

Current authorization, one-use proof consumption and durable operation state are separate decisions. An idempotency key identifies an operation; it is not a credential. Authorization: May this caller perform this operation now?; Proof consumption: May this authority be used for this request?; Operation state: For this scoped key and payload, return or reconcile the stored result.Current authorization, one-use proof consumption and durable operation state are separate decisions. An idempotency key identifies an operation; it is not a credential. Authorization: May this caller perform this operation now?; Proof consumption: May this authority be used for this request?; Operation state: For this scoped key and payload, return or reconcile the stored result.

Run the difference locally

Save the following as article-demo.mjs and run node article-demo.mjs. It uses Node built-ins. The two hardcoded proof labels stand in for already-validated credentials. They are deliberately unsuitable as real credentials. The program isolates one-use consumption from authentication, expiry, and binding.

// Process-local teaching model. Not a security implementation.
import assert from 'node:assert/strict';

const results = new Map();
const unusedProofs = new Set(['proof-A', 'proof-B']);
let effects = 0;

async function perform(operation) {
  if (!results.has(operation)) {
    const result = Promise.resolve().then(() => ++effects);
    results.set(operation, result);
  }
  return results.get(operation);
}

async function protectedPerform(operation, proof) {
  // Assume proof authenticity, expiry, session and action binding passed.
  // This check/delete is synchronous in ONE process, not across servers.
  if (!unusedProofs.delete(proof)) throw new Error('used or unknown proof');
  return perform(operation);
}

assert.deepEqual(await Promise.all([perform('order-1'), perform('order-1')]), [1, 1]);
console.log('same operation twice:', effects); // 1
await perform('order-2');
console.log('different operation:', effects); // 2

const race = await Promise.allSettled([
  protectedPerform('order-3', 'proof-A'),
  protectedPerform('order-3', 'proof-A'),
]);
assert.deepEqual(race.map(x => x.status), ['fulfilled', 'rejected']);
console.log('same proof twice:', race.map(x => x.status).join(', '));
const recovered = await protectedPerform('order-3', 'proof-B');
assert.equal(recovered, race[0].value);
assert.equal(effects, 3);
console.log('fresh proof, same operation:', effects); // still 3

Expected output, reproduced locally on Node v26.8.1:

same operation twice: 1
different operation: 2
same proof twice: fulfilled, rejected
fresh proof, same operation: 3

Read the results carefully

The first pair shares one operation identifier. Both calls receive the same result and the effect counter advances once. The second identifier creates another effect. No security decision prevented it: this portion deliberately models deduplication only.

The next pair submits the same proof twice. One call removes it from the unused set; the other fails. The asynchronous operation starts after the first call has reserved its result. Finally, a fresh proof requests that same operation. It retrieves the earlier result without another effect. Retry recovery and one-use authority can therefore coexist.

This program demonstrates interleaved asynchronous calls in one JavaScript process. It does not test multiple workers or database concurrency. Promise.allSettled is not a load test. The synchronous set deletion illustrates the required indivisible claim; it is not a distributed locking strategy.

What changes in production?

Start by authenticating the caller and authorizing the action. Validate proof authenticity and freshness, then bind the accepted authority to the intended session and action. Where the design uses an operation-specific credential, bind the operation too. A server must perform those checks rather than trust client assertions. OWASP recommends unique authorization credentials for each operation and server-side enforcement.

Scope idempotency records to the authenticated owner and action. Store the validated operation parameters and reject reuse of an operation identifier with conflicting parameters. Decide what happens while an operation is pending and how long its result remains recoverable. Do not accept another user's cached response merely because a key matches.

Provider behavior also matters. For example, Stripe documents API v1 idempotency as saving the first executed request's status and body, with parameter comparison on reuse. Its documentation distinguishes API v2 behavior. Check the contract for the API and version you actually call.

Across processes, proof consumption requires an atomic operation in shared authoritative state. Coordinate consumption, operation reservation, and the business write with a transaction or a durable recovery design. Otherwise a crash can consume authority without completing the operation, or complete the operation before recording its result. External side effects need their own deduplication and reconciliation contract.

Test the failure paths before enforcement

A useful regression suite covers duplicate submissions arriving together, a changed payload under the same identifier, wrong-session and wrong-action evidence, expired evidence, and a response lost after success. Add process restarts and separate workers against the real shared store. Check that retries recover the correct result without creating a second effect.

Roll out changes with observation of legitimate completion, retry recovery, and rejected reuse before enabling a stricter policy. A freshness timestamp alone does not record consumption: multiple copies can arrive within its validity window. Our replay attack guide explains that threat, while why browser proof needs server state develops the verification architecture.

Method: Prepared by MANDATE with AI assistance. The examples were executed locally on October 15, 2026, with assertions for the displayed results. A separate fixture also checked parameter conflicts, session and operation binding, and expiry using synthetic server-held records and a synthetic clock. These checks establish the behavior of the teaching examples only; they do not establish production deployment, security effectiveness, or performance.

Choose the neighboring control deliberately: The replay-attack guide explains rejection of captured authority, while the gateway rate-limit guide explains admission volume and counting keys. Neither owns the durable operation result that this example coordinates.

More from the blog

FRAUD PREVENTION API

Fraud Prevention APIs: Where Browser Verification Fits

Compare fraud prevention API layers: identity, transaction risk, browser verification, replay protection, and safe retries for web applications.

CARD TESTING

Card Testing Attacks: A Practical Engineering Guide

Detect card testing before payment-provider calls. Review attempt signals, safe logging, rate limits, browser proof, and an add-payment-method rollout.

CARDING

What Is Carding? How Card Fraud Testing Hits Checkouts

What is carding? A plain-English guide to card-not-present fraud, how bots validate stolen cards, and the defenses merchants can apply to checkouts and signups.