Network errors and client retries are normal in distributed systems. Without idempotency keys, a retry after a timeout can create two Stripe Payment Intents for the same purchase — charging the customer twice. CWE-362 (Race Condition) and ISO 25010 functional-correctness both apply. Stripe's idempotency key mechanism guarantees that if you send the same key twice, you get the same response without creating a second charge — but you must opt in explicitly on every payment initiation call.
Medium because duplicate charges are recoverable via refund but cause customer trust damage, support burden, and potential chargeback risk.
Pass an idempotency key on every Stripe payment initiation call. Tie the key to the specific purchase intent so retries within a session return the existing object rather than creating a new one.
// Stable idempotency key: user + price + minute-level timestamp
// Minute-level granularity: allows retries within the same intent window
// but a new attempt a minute later gets a fresh key (new purchase intent)
const idempotencyKey = `checkout-${userId}-${priceId}-${Math.floor(Date.now() / 60000)}`
const session = await stripe.checkout.sessions.create(
{
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/pricing`,
},
{ idempotencyKey }
)
Alternatively, check for an existing incomplete checkout session in your database before creating a new one, and return the stored session URL on retry.
ID: saas-billing.financial-data.payment-idempotency
Severity: medium
What to look for: Check API routes that initiate payments, create checkout sessions, or create subscriptions. Look for Stripe idempotency key usage (idempotencyKey option on Stripe API calls). Idempotency keys prevent duplicate charges when a network error causes a client to retry a request. Check whether checkout session creation or payment intent creation endpoints can be safely called multiple times without creating duplicate charges.
Pass criteria: Count every payment initiation endpoint — at least 1 must have idempotency protection. Payment initiation endpoints use Stripe idempotency keys, OR use Stripe Checkout Sessions in a way that naturally prevents duplicates (creating a session returns the same session if called again with the same parameters). Checkout session IDs stored in the database prevent duplicate session creation for the same purchase intent.
Fail criteria: Payment intent or charge creation endpoints can be called multiple times without idempotency protection, creating the risk of duplicate charges on network retry.
Skip (N/A) when: No payment initiation code detected (application uses only webhooks to receive payment events, not to initiate payments).
Detail on fail: "POST /api/billing/checkout creates a new Stripe Payment Intent on every call without idempotency key — network retries can cause duplicate charges" or "No idempotency keys used on any Stripe API calls in payment flow"
Remediation: Use Stripe idempotency keys tied to the user's intent:
// Generate a stable idempotency key tied to this specific purchase intent
const idempotencyKey = `checkout-${userId}-${priceId}-${Date.now().toString().slice(0, -3)}`
// (truncate to minute-level to allow retries within the same minute but prevent
// accidental reuse across different purchase intents)
const session = await stripe.checkout.sessions.create(
{ customer: customerId, line_items: [...], mode: 'subscription' },
{ idempotencyKey }
)
Alternatively, check for an existing incomplete checkout session before creating a new one, and return the existing session URL if found.