Duplicate submissions prevented via idempotency token
Why it matters
Network timeouts cause users and retry logic to resubmit financial transactions. Without idempotency keys, each retry creates a new transaction: one timeout during a fund transfer can result in two or more debits from the same account. CWE-362 (race condition) covers this class of failure; OWASP A04 (Insecure Design) applies to payment flows without duplicate-submission protection. Idempotency is a standard transaction-integrity control across PCI-DSS-scoped systems (enforced through Requirement 6 engineering practices) and the Stripe / Square API security guides explicitly require idempotency keys on financial endpoints. Client-side submit-button disabling is not a substitute — it doesn't protect against server retries, load balancer replays, or network-level retransmission.
Severity rationale
Medium because network retries in the absence of idempotency keys directly cause duplicate transactions and double charges, producing immediate financial harm.
Remediation
Generate a UUID idempotency key client-side, send it as a header, and check + store it server-side before processing any write.
// src/components/TransferForm.tsx
const idempotencyKey = crypto.randomUUID();
await fetch('/api/transfers', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ amountCents }),
});
// src/app/api/transfers/route.ts
const key = req.headers.get('idempotency-key');
if (!key) return Response.json({ error: 'Missing Idempotency-Key' }, { status: 400 });
const existing = await db.idempotencyLog.findUnique({ where: { key } });
if (existing) return Response.json(existing.result, { status: 200 });
const result = await processTransfer(body);
await db.idempotencyLog.create({ data: { key, result } });
Detection
-
ID:
idempotency-key -
Severity:
medium -
What to look for: Count all financial transaction endpoints (transfer, payment, withdrawal, deposit). For each, check whether an idempotency key mechanism exists: (1) client generates and sends a UUID, (2) server checks for existing key before processing, (3) server stores the key with the result. Quote the idempotency implementation found (header name, storage mechanism). Report: "X of Y transaction endpoints implement idempotency keys."
-
Pass criteria: At least 100% of financial transaction endpoints accept and validate an idempotency key. The key is stored server-side and duplicate requests return the original result without reprocessing. No more than 0 transaction endpoints lack idempotency protection.
-
Fail criteria: Any financial transaction endpoint processes requests without idempotency checking, or the key is accepted but not persisted.
-
Do not pass when: The idempotency key is checked client-side only (e.g., disabling the submit button) — network retries bypass client-side guards.
-
Skip (N/A) when: The project has no financial transactions (0 write endpoints for monetary operations found).
-
Detail on fail: Example:
"Transfer endpoint has no idempotency key. If a request times out and is retried, the transfer would be processed twice."or"Idempotency key accepted but not stored or validated" -
Cross-reference: The API Security audit covers broader request validation and replay protection patterns that complement idempotency enforcement.
-
Remediation: Implement idempotency keys in the client form and server route:
Client-side in
src/components/TransferForm.tsx:import { v4 as uuidv4 } from 'uuid'; const idempotencyKey = uuidv4(); const response = await fetch('/api/transfer', { method: 'POST', headers: { 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ amountCents, accountNumber }), });Server-side in
src/app/api/transfers/route.ts:export async function POST(req) { const idempotencyKey = req.headers['idempotency-key']; if (!idempotencyKey) { return new Response('Missing Idempotency-Key', { status: 400 }); } const existing = await db.idempotencyResults.findUnique({ where: { key: idempotencyKey }, }); if (existing) { return new Response(JSON.stringify(existing.result), { status: 200 }); } const result = await processTransaction(req.body); await db.idempotencyResults.create({ data: { key: idempotencyKey, result: JSON.stringify(result) }, }); return new Response(JSON.stringify(result), { status: 201 }); }
External references
- cwe · CWE-362 — Race Condition / TOCTOU
- owasp:2021 · A04 — Insecure Design
Taxons
History
- 2026-04-18·v1.0.0·Initial import from finserv-form-validation·automated