# Duplicate submissions prevented via idempotency token

- **Pattern:** `ab-001454` (`finserv-form-validation.error-edge-cases.idempotency-key`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001454
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001454)

## 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.

```ts
// src/components/TransferForm.tsx
const idempotencyKey = crypto.randomUUID();
await fetch('/api/transfers', {
  method: 'POST',
  headers: { 'Idempotency-Key': idempotencyKey },
  body: JSON.stringify({ amountCents }),
});
```

```ts
// 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`:**
  ```ts
  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`:**
  ```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
- owasp A04

Taxons: data-integrity, error-resilience

HTML version: https://auditbuffet.com/patterns/ab-001454
