RACE CONDITIONS IN MONEY PATHS

AI generators write the read-then-write pattern naturally. Read the balance, check it's enough, subtract the amount, write it back. Looks fine. Two of those running at the same time double-spends. The bug is in the gap between the read and the write, and it has been in software since software has had concurrency.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.

The race

The pattern. AI generates an endpoint that subtracts from a balance:

app.post('/withdraw', requireAuth, async (req, res) => {
  const { amount } = req.body
  const user = await db.user.findUnique({ where: { id: req.session.userId } })
  if (user.balance < amount) return res.status(400).send('Insufficient funds')
  await db.user.update({
    where: { id: req.session.userId },
    data: { balance: user.balance - amount }
  })
  res.json({ ok: true })
})

Three database operations: read balance, check, write balance. The check happens at step two, the write at step three. The gap between them is small in human time but vast in database time.

Two concurrent requests for the same user, both withdrawing $50 from a $100 balance:

  • Request A: reads balance = 100. Check: 100 >= 50, OK.
  • Request B: reads balance = 100. Check: 100 >= 50, OK.
  • Request A: writes balance = 50.
  • Request B: writes balance = 50.

The user successfully withdrew $100 from a $100 balance. They got two withdrawals out. The balance ended at $50, not $0.

This is the most basic race in a money path and we still find it. AI generators reproduce the pattern because the unsafe version is the shortest version.

Where else it shows up

The same shape applies broadly:

  • Coupon redemption. Coupon should be one-use. Concurrent redeem requests both pass the “is it used?” check, both mark it used, both grant the discount.
  • Free-tier limits. “User can have at most 5 active widgets.” Concurrent creation of a 5th and a 6th both pass the check.
  • Paid-flag flips. Webhook from Stripe arrives saying “subscription canceled,” concurrent admin action also touches the flag — outcome depends on order.
  • Idempotency keys. Stripe charges include an idempotency key to prevent double-charging. If your code reads/writes the idempotency record without a lock, concurrent retries can both pass through.
  • Inventory. “Last item in stock,” two concurrent purchases — both succeed, you owe one customer a refund and an apology.

Live: https://gapbench.vibe-eval.com/site/race-condition-balance/.

Fixes

The fixes are all about making the read-check-write atomic. Three patterns in increasing order of robustness:

1. Conditional UPDATE

const result = await db.user.updateMany({
  where: { id, balance: { gte: amount } },
  data: { balance: { decrement: amount } }
})
if (result.count === 0) return res.status(400).send('Insufficient funds')

The condition and the decrement happen in one statement. The database evaluates the WHERE clause at write time, with row-level locking. If two concurrent requests fire, the second sees the updated balance and fails the WHERE clause.

This is the simplest fix and works for most cases. It doesn’t handle cases where the rule is more complex than a simple comparison.

2. Transaction with SELECT FOR UPDATE

await db.$transaction(async (tx) => {
  const user = await tx.$queryRaw`SELECT balance FROM users WHERE id = ${id} FOR UPDATE`
  if (user.balance < amount) throw new Error('Insufficient funds')
  await tx.user.update({ where: { id }, data: { balance: user.balance - amount } })
})

The transaction holds a row-level lock from SELECT FOR UPDATE until commit. Concurrent transactions block until the first finishes. Slower than the conditional UPDATE under contention but handles arbitrary checks.

3. Optimistic concurrency with version

const user = await db.user.findUnique({ where: { id } })
if (user.balance < amount) return res.status(400).send('Insufficient funds')
const result = await db.user.updateMany({
  where: { id, version: user.version },
  data: { balance: user.balance - amount, version: user.version + 1 }
})
if (result.count === 0) return res.status(409).send('Try again')

The version column ensures we only write if no one else wrote since we read. Concurrent requests get a 409 and have to retry. Works without holding locks, scales better under high contention.

For most apps, pattern 1 is the right answer. Reach for 2 when you need to do multiple checks in the same transaction. Reach for 3 when contention is high and you need scalability.

A specific incident — coupon double-redemption

Anonymized. An e-commerce SaaS launched a promo: a one-use coupon code worth 50% off, given to the first 1,000 customers who signed up for the newsletter. The team built it with the expected pattern: read the coupon, check used_at IS NULL, mark used, apply discount.

Three checks. Three database round-trips. No transaction. No row lock. No atomic update.

Within a day, a Reddit thread surfaced about the promo. People realized that if you fired the redemption request multiple times in parallel from different curl windows, sometimes more than one succeeded. The window was tiny — milliseconds — but with a one-line bash script, you could land it.

People started double-redeeming. The team saw their actual redeemed-coupon count exceed the issue count by 4x in the first 48 hours. They shut the promo down, refunded customers who had paid for legitimately discounted orders (after sorting through which were legitimate), and rebuilt the redemption with an atomic UPDATE. The fix was three lines. The damage was real money.

The lesson: any time you have a “check then act” pattern in a money path, assume it’s racy unless you’ve explicitly made it atomic. Atomic means database-level — a single SQL statement with a WHERE clause that performs the check, or a transaction with a row lock. Application-level “I’ll just check first” is not atomic.

A specific incident — paid-flag flip during webhook race

Anonymized, different shape. A SaaS had a Stripe subscription that auto-renewed monthly. The webhook handler updated the user’s pro_until date based on the renewal event. The same date was also touched by an admin “extend trial” panel that the support team used.

A customer’s subscription renewed via webhook (extending pro_until by 30 days). At nearly the same moment, a support rep ran the admin trial-extension (extending pro_until by 14 days from the current value). Race: both reads got the original value. Both writes happened. The result was either webhook+14 (if webhook won the write) or admin+30 (if admin won), but never webhook+30 then admin+14 — meaning either the webhook’s renewal or the admin’s extension was lost.

The fix here wasn’t an atomic update — it was making the writes commutative. Both operations were rewritten as pro_until = MAX(pro_until, new_target_date), which doesn’t depend on read order. Concurrent writes both pick the larger value; race becomes irrelevant.

The lesson: not every race needs locking. Sometimes the right fix is to write code that’s correct under any interleaving. CRDT-style operations (max, min, sum, set-add) are race-free.

A taxonomy of money-path races

Worth listing because each has its own pattern:

  1. Balance withdrawal — read balance, check, write balance. The classic. Fix: conditional UPDATE.
  2. Coupon / one-use code — read used flag, check, mark used. Fix: conditional UPDATE on used_at IS NULL.
  3. Resource limits — count active resources, check limit, create new. Fix: SELECT FOR UPDATE on a lock row, or UPDATE with conditional count check.
  4. Inventory — read stock, check, decrement. Fix: conditional UPDATE on stock > 0.
  5. Idempotency keys — check if key already used, if not, process. Fix: INSERT … ON CONFLICT, or SELECT FOR UPDATE on the idempotency record.
  6. Subscription state — read state, decide based on state, transition state. Fix: state machine with explicit valid transitions, written via conditional UPDATE matching the from-state.
  7. Reservation hold — check resource available, mark held, charge. Fix: SELECT FOR UPDATE on the resource, plus a TTL on the hold to recover from crashes.

Each pattern has a database-side fix. AI codegen rarely produces the database-side fix on the first attempt; it tends to write the application-level read-check-write that races.

Wrong fix vs right fix — coupon

// WRONG: read, check, write — racy
const coupon = await db.coupon.findUnique({ where: { code } })
if (coupon.usedAt) return res.status(400).send('Already used')
await db.coupon.update({ where: { code }, data: { usedAt: new Date() } })
applyDiscount(user, coupon.amount)
// WRONG: transaction without row lock — still racy on some isolation levels
await db.$transaction(async (tx) => {
  const coupon = await tx.coupon.findUnique({ where: { code } })
  if (coupon.usedAt) throw new Error('Already used')
  await tx.coupon.update({ where: { code }, data: { usedAt: new Date() } })
})
// RIGHT: atomic conditional UPDATE
const result = await db.coupon.updateMany({
  where: { code, usedAt: null },
  data: { usedAt: new Date() }
})
if (result.count === 0) return res.status(400).send('Already used or not found')
applyDiscount(user, coupon.amount)
// RIGHT: SELECT FOR UPDATE if you need additional logic in the same transaction
await db.$transaction(async (tx) => {
  const [coupon] = await tx.$queryRaw`SELECT * FROM coupon WHERE code = ${code} FOR UPDATE`
  if (coupon.usedAt) throw new Error('Already used')
  await tx.coupon.update({ where: { code }, data: { usedAt: new Date() } })
  // ... more logic in the same transaction
}, { isolationLevel: 'Serializable' })

Cross-stack notes

  • Postgres + Prisma: updateMany with conditional WHERE for atomic updates; $queryRaw ... FOR UPDATE for explicit locks.
  • MySQL + ORMs: SELECT ... FOR UPDATE requires an explicit transaction; some ORMs make this verbose.
  • MongoDB: findOneAndUpdate({code, usedAt: null}, {$set: {usedAt: Date.now()}}) is atomic. Multi-document transactions exist but are heavier-weight.
  • DynamoDB: Conditional UPDATE with attribute_not_exists is the natural fit. Native to the API.
  • Redis (for state, not for money): SET key value NX for “set if not exists.” Useful for distributed locks but Redis itself is not where money should live.

How we detect

We send concurrent requests to suspected money endpoints — typically 5 to 50 simultaneous requests, all attempting the same operation against the same target — and observe whether the resulting state is consistent. For balance withdrawals: did total amount removed equal sum of successful responses? For coupons: was each coupon used at most once across all concurrent attempts? For limits: was the limit ever exceeded?

The detection requires concurrency and visibility into the resulting state. Static scanners can flag the read-then-write pattern but can’t confirm exploitability. Some races are time-sensitive and only manifest under load — we use HTTP/2 multiplexing where available to maximize concurrency without round-trip overhead.

CWE / OWASP

  • CWE-362 — Concurrent Execution using Shared Resource with Improper Synchronization
  • CWE-367 — Time-of-check Time-of-use Race Condition
  • CWE-841 — Improper Enforcement of Behavioral Workflow
  • OWASP Top 10 — A04:2021 Insecure Design

Reproduce it yourself

COMMON QUESTIONS

01
What is TOCTOU?
TOCTOU — Time of Check to Time of Use — is the gap between when you check a condition and when you act on it. If anything else can change the condition during the gap, your check can be true at check time and your action wrong at use time. In money paths: read balance is $100, check it's >= $50, subtract $50. Two concurrent requests both pass the check; both subtract. The user spent $100 but only had $100 to spend.
Q&A
02
Why do AI generators reproduce this?
Because the read-then-check-then-write pattern is the natural way to write the code. The model knows about transactions and locks but doesn't always reach for them when the user just asked for 'a withdraw endpoint.' The unsafe version is two queries; the safe version is one with an atomic update or a transaction with a row lock.
Q&A
03
Which money paths are most often affected?
Balance withdraws and transfers. Coupon redemption (each coupon should be redeemable once; concurrent redeems double-redeem). Paid-flag flips. Resource limits — 'the user can have at most 5 active integrations' is a check that races. Anything where the rule is 'do this if condition X is true' and the condition depends on prior state.
Q&A
04
What's the fix?
Atomic updates at the database level. SELECT FOR UPDATE inside a transaction holds a row lock until commit, ensuring no concurrent transaction sees stale state. UPDATE ... WHERE balance >= amount in a single statement is safer because the database evaluates the condition at write time. Some databases support optimistic concurrency with version columns. The pattern that doesn't work: reading and writing in separate queries without a lock between them.
Q&A
05
Where can I see this on a real URL?
https://gapbench.vibe-eval.com/site/race-condition-balance/. Adjacent: https://gapbench.vibe-eval.com/site/stripe-paid-trust/ and https://gapbench.vibe-eval.com/site/mass-assignment/ for related shape.
Q&A
06
What CWE does this map to?
CWE-362 (Concurrent Execution using Shared Resource with Improper Synchronization), CWE-367 (Time-of-check Time-of-use Race Condition), CWE-841 (Improper Enforcement of Behavioral Workflow). OWASP A04:2021 (Insecure Design).
Q&A

TEST YOUR MONEY PATHS

We send concurrent requests against your balance, paid-flag, and limit endpoints and observe which ones produce inconsistent state.

RUN THE SCAN