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.
Medium because network retries in the absence of idempotency keys directly cause duplicate transactions and double charges, producing immediate financial harm.
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 } });
ID: finserv-form-validation.error-edge-cases.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 });
}