Refund Race Conditions: Double Credits From Parallel Cancel Requests
Refund flows read order state, then write a credit. Between those steps another request can slip through. If both see paid and both issue refunds, someone gets paid twice.
This is classic TOCTOU with a cash register attached.
Build a tiny refund lab
Use the smallest purchasable item the program allows. Complete one order you fully control. Note every refund path: customer "cancel," support form, POST /orders/{id}/refund, and any wallet reverse endpoint. Prefer stores with instant wallet credit over card refunds when you need fast feedback—wallet balances are easier to screenshot twice.
From two tabs or a Burp turbo/intruder pair, send identical refund requests at the same moment. Group them so they leave together. Look for:
- Two
refund_idvalues - Wallet balance increased by 2× the order amount
- Order status flipping in a weird sequence (
refunded→refundedagain) - Payment provider showing two refund objects for one charge
One success and one 409 is healthy locking. Two successes is the bug.
Where the race usually hides
Idempotency keys fix a lot of payment APIs—until the app generates a new key per click. If the client sends Idempotency-Key: <uuid>, replay the same key and a fresh key across parallel calls. Same key should collapse; different keys may double-spend the refund logic even when the PSP would have been safe.
Partial refunds are juicier. Race two partial refunds that each claim 60% of the total. Or race a full refund against an item-level refund. Cancel-versus-refund is another pair: one request restocks and credits shipping, the other refunds payment.
Store credit refunds versus original-payment refunds can both fire if the chooser UI posts different refund_destination values at once. Aim both at wallet if that is faster to observe, and confirm the card path separately only when the program allows small live refunds.
Do not hammer production with hundreds of checkouts. Two to ten parallel requests on one owned order is enough. If you observe double credit, stop and preserve logs.
Evidence that survives finance review
Record timestamps, both response bodies, the order timeline, and the wallet or PSP ledger. A unique memo or canary SKU name helps prove both refunds refer to your order. Severity is straightforward when money leaves twice; note whether the second credit is reversible and whether inventory double-restocked.
Recommend a transactional state machine: transition paid → refund_pending → refunded under a row lock or UPDATE … WHERE status = 'paid', enforce a single refund aggregate that cannot exceed captured amount, and pass a stable idempotency key derived from order_id + refund_attempt to the processor.
Support-agent refund tools sometimes lack the locks the customer portal has. If the program allows two roles you control, race the shopper cancel against an agent refund on the same order ID. Dual credits still count when both actors are you.
PortSwigger's state-machine research is useful vocabulary here. You are not fuzzing strings—you are colliding transitions. Say that clearly and attach the parallel traces.