Idempotency keys are used on all state-changing charge requests
Why it matters
Without idempotency keys, a network timeout on a charge request leaves your app in an ambiguous state: the payment may or may not have succeeded, and a retry creates a second charge for the same order. This is a real operational failure — customers who retry a checkout after a timeout get double-charged, support costs spike, and refund rates increase. CWE-362 (Race Condition) captures the concurrent-retry scenario that idempotency keys prevent. Beyond customer impact, double charges on high-volume stores compound into significant financial reconciliation problems. An idempotency key ensures that Stripe treats any retry of the same logical transaction as a no-op, returning the original result instead of creating a new charge.
Severity rationale
High because missing idempotency keys on charge calls make double-charging customers a near-certainty under any network instability or client retry logic.
Remediation
Generate a unique idempotency key per logical transaction and pass it to every state-changing Stripe call. Derive it from your order ID for natural retry deduplication.
// app/api/create-payment-intent/route.ts
import { v4 as uuidv4 } from 'uuid'
// Stable key: reusing orderId means retries hit the same PaymentIntent
const idempotencyKey = `pi-${orderId}`
const paymentIntent = await stripe.paymentIntents.create(
{
amount: amountInCents,
currency: 'usd',
customer: stripeCustomerId,
},
{ idempotencyKey }
)
Apply the same pattern to stripe.refunds.create() and any other state-changing call. Store the idempotency key alongside the order record so that application-level retries always reuse the same key.
Detection
-
ID:
idempotency-keys-all-charges -
Severity:
high -
What to look for: Count every backend call that creates charges, payment intents, refunds, or subscription records. For each call, check whether an idempotency key is provided. For Stripe, look for the
idempotencyKeyoption instripe.paymentIntents.create(),stripe.charges.create(), andstripe.refunds.create()calls. For other providers, look for equivalent idempotency header parameters. Check that the idempotency key is unique per logical transaction (typically a UUID generated per request or derived from a stable order identifier). Report the ratio: "X of Y state-changing payment API calls include idempotency keys." -
Pass criteria: 100% of state-changing payment API calls include a unique idempotency key passed to the provider. No more than 0 calls should lack an idempotency key. Keys are generated freshly per transaction (not reused across different transactions).
-
Fail criteria: Any charge, payment intent creation, or refund call is made without an idempotency key. This exposes the app to double-charging if the client retries due to a network error.
-
Skip (N/A) when: The project has no server-side payment charge logic (e.g., only uses PaymentIntents created by Stripe Checkout client-side, where Stripe handles idempotency).
-
Detail on fail: Name the endpoint and the missing parameter. Example:
"POST /api/create-charge calls stripe.paymentIntents.create() without idempotencyKey — network retries could create duplicate charges" -
Remediation: Generate a unique idempotency key per transaction and pass it with every charge call:
import { v4 as uuidv4 } from 'uuid' // Generate per-order, store with order record so retries reuse same key const idempotencyKey = `order-${orderId}-${uuidv4()}` const paymentIntent = await stripe.paymentIntents.create( { amount: amountInCents, currency: 'usd', customer: stripeCustomerId, }, { idempotencyKey } )Alternatively, derive the key from a stable order ID so retries naturally reuse the same key:
idempotencyKey: \pi-order-${orderId}``.
External references
- cwe · CWE-362 — Concurrent Execution Using Shared Resource with Improper Synchronization (Race Condition)
- iso-25010:2011 · reliability.fault-tolerance
Taxons
History
- 2026-04-18·v1.0.0·Initial import from ecommerce-payment-security·automated