Idempotency keys for payment and mutation endpoints
Why it matters
Without idempotency protection, a network timeout followed by a client retry on a payment endpoint triggers a duplicate charge (CWE-362, race condition via repeated mutation). A double-click on a checkout button produces two identical Stripe createSubscription calls. The business consequence is immediate: duplicate charges require manual refunds, generate chargebacks, and erode customer trust. This is not a theoretical risk — network retries are the default behavior of most HTTP clients and mobile SDKs, making duplicate invocation the expected path under degraded network conditions.
Severity rationale
High because duplicate payment execution from retries is a common real-world event that causes immediate financial and trust damage requiring manual remediation.
Remediation
Pass idempotency keys to every Stripe API call in your payment route handlers, and store key-to-result mappings in Redis or your database for your own mutation endpoints:
// For Stripe calls — always pass idempotencyKey
const idempotencyKey = request.headers.get('Idempotency-Key') ?? crypto.randomUUID()
const subscription = await stripe.subscriptions.create(
{ customer: customerId, items: [{ price: priceId }] },
{ idempotencyKey }
)
For your own endpoints, check whether the key was already processed before executing business logic, and return the cached response on a hit. The client generates and stores the key before the first attempt, then retries with the same key on failure. Document this contract in your API reference so integrators know to supply the header.
Detection
- ID:
idempotency-keys - Severity:
high - What to look for: Enumerate all API routes that perform irreversible or expensive operations: payment processing, subscription management, sending emails, sending notifications, creating orders, or other mutations where duplicate execution would cause problems. Check whether these endpoints support or require an
Idempotency-Keyheader (or equivalent). Also check: if using Stripe, areidempotencyKeyoptions passed to Stripe API calls? Are idempotency keys stored and checked to prevent replay? - Pass criteria: At least 100% of payment endpoints and critical mutation endpoints either (a) implement idempotency key checking with a persistent store to deduplicate duplicate requests, or (b) use a payment provider's built-in idempotency (e.g., Stripe's
idempotencyKeyparameter) consistently. - Fail criteria: Payment endpoints or email-sending endpoints have no idempotency protection — a network retry or double-click could trigger duplicate charges, duplicate emails, or duplicate resource creation.
- Skip (N/A) when: No payment processing and no expensive/irreversible mutation endpoints exist. Signal: no Stripe, PayPal, or other payment library detected; no email-sending library detected; no endpoint logic that creates orders or triggers financial operations.
- Detail on fail: Example:
POST /api/billing/subscribe has no idempotency key. Identify the unprotected endpoints (e.g., "POST /api/billing/subscribe has no idempotency key; Stripe createSubscription call lacks idempotencyKey parameter"). Max 500 chars. - Remediation: Add idempotency protection in
app/api/route handlers to payment and critical mutation endpoints. For Stripe API calls, always pass anidempotencyKey:stripe.subscriptions.create(params, { idempotencyKey: clientSuppliedKey || crypto.randomUUID() }). For your own endpoints, accept anIdempotency-Keyheader, store it in Redis or your database with the request result, and on duplicate requests return the cached response. The client should generate and store the key before making the request, and retry with the same key on network failures. Stripe's own documentation has a clear idempotency implementation guide.
External references
- cwe · CWE-362 — Race Condition (check-then-act idempotency bypass)
- iso-25010:2011 · functional-suitability.functional-correctness — Functional Correctness
Taxons
History
- 2026-04-18·v1.0.0·Initial import from saas-api-design·automated