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.
High because missing idempotency keys on charge calls make double-charging customers a near-certainty under any network instability or client retry logic.
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.
ID: ecommerce-payment-security.payment-integration.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 idempotencyKey option in stripe.paymentIntents.create(), stripe.charges.create(), and stripe.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}``.