Two tellers, one balance, paid twice
Watch it happen
Two tellers read the same balance of 100 at once, both approve a 100 withdrawal, and the bank pays out 200.
Mallory walks in. She has 100 in her account.
Mallory says: I’d like to withdraw.In bank terms and app terms
| In the bank | In your app |
|---|---|
Are you exposed?
Your code reads a value, checks it in JavaScript, then writes it back in a separate query. You have one-time actions (coupons, gift cards, votes, refunds) that only check “has this been used?” before acting. You have never tested what happens when the same request is sent 10 times in parallel.
Do the check and the change in one atomic statement: UPDATE … SET balance = balance - 50 WHERE balance >= 50. Wrap multi-step changes in a transaction that also locks the row (SELECT … FOR UPDATE) or uses SERIALIZABLE isolation with retries. A plain transaction alone is not enough. Add unique constraints and idempotency keys so a repeated coupon, order or refund can only happen once.
It has happened to real companies
Starbucks (2015) — Security researcher Egor Homakov found that by using two sessions of the same account at once, he could make Starbucks' gift card transfer process the same $5 balance twice, ending up with $20 across two cards.
What it looks like in code
// BAD: Check-then-act without locking
export async function POST(request: Request) {
const { amount } = await request.json()
// Read current balance
const credits = await db.query("credits", `user_id = '${userId}'`)
const balance = credits[0].balance
// Check
if (balance < amount) return error("Insufficient")
// GAP: Another request can read the same balance here
// Act
await db.update("credits", { balance: balance - amount })
return success({ redeemed: amount })
}
Ask your AI tool to fix it
Paste this into Cursor, Claude, Lovable, Bolt or whatever you build with.
Find every endpoint that changes money, credits, stock, coupons, votes or refunds. Replace read-check-write sequences with a single atomic UPDATE with a WHERE condition, or a database transaction with row locking. Add unique constraints or idempotency keys for one-time actions. Then write a test that sends 10 parallel identical requests and asserts only the allowed number succeed.Where this comes from
The facts, figures and fixes on this page were checked against these sources on 25 Oct 2026. They are linked so you can check for yourself.
- Explicit Locking (row-level locks)PostgreSQLrow locks such as FOR UPDATE make a second writer wait
- Transaction IsolationPostgreSQLserializable needs retries; plain read committed alone does not prevent it
- SELECT (locking clause)PostgreSQLSELECT ... FOR UPDATE locks the selected rows
- Constraints (unique)PostgreSQLunique constraints stop a coupon or refund being recorded twice
- Idempotent requestsStripeidempotency keys let a repeated request run only once
- Hacking Starbucks for unlimited coffeeSakurity (Egor Homakov)parallel gift card transfers: two $5 transfers, 20 total (2015)
Spot a mistake or something out of date? Report a correction · How these guides are made and checked
The free, open-source scanner checks for most of the problems in these guides.