Blog/Web Security
Web SecurityAugust 11, 2026 · 7 min read

Race Conditions in Web Apps: When Two Requests Beat Your Check

A wallet endpoint withdraws funds. The logic is exactly what a code review would approve:

const account = await Account.findById(id);
if (account.balance >= amount) {        // check
  account.balance -= amount;            // act
  await account.save();
  await sendMoney(amount);
}

Fire this once and it's correct. Fire it twenty times simultaneously with amount = 100 against a balance = 100, and something ugly happens: all twenty requests read balance = 100 before any of them writes, so all twenty pass the >= check, all twenty deduct, and you've sent $2000 from a $100 account. This is a race condition — specifically a time-of-check-to-time-of-use (TOCTOU) bug — and the reason it slips through review is that the code is correct in isolation. It only breaks when two requests overlap in the gap between the check and the act.

Where the gap bites

Any "verify a condition, then change state" flow is a candidate. The high-value ones:

  • Balances and payments — withdraw or transfer more than you have by overlapping requests
  • One-time codes — redeem a coupon, gift card, or referral bonus multiple times
  • Inventory and limited offers — buy the last item several times; claim more seats than exist
  • Rate limits and quotas — slip many actions through a "max N" check that reads then increments
  • Account creation / uniqueness — register two accounts with the same email if the uniqueness check and insert aren't atomic

Attackers don't need luck. They send a burst of identical requests in parallel (modern "single-packet" techniques shrink the timing window to microseconds) specifically to land inside the gap.

The fix: make check-and-act a single atomic step

The gap exists because the read and the write are two operations. Close it by making the condition part of the write, so the database evaluates and mutates in one indivisible action — or by holding a lock so only one request is in the critical section at a time.

-- The check lives INSIDE the update. Only one concurrent statement
-- can satisfy 'balance >= amount' and decrement; the rest affect 0 rows.
UPDATE accounts
 SET balance = balance - :amount
WHERE id = :id
 AND balance >= :amount;         -- condition + mutation, atomic

-- Then act on the ROW COUNT, not on a prior read:
--   rows affected = 1  -> the withdrawal succeeded, proceed
--   rows affected = 0  -> insufficient funds OR lost the race, reject
-- Because the DB serializes row writes, exactly one of N racing
-- requests updates the row; the others see 0 and stop.

The principles

  • Fold the check into the write. WHERE balance >= :amount (or WHERE status IS NULL) makes the database enforce the condition atomically. Then branch on rows-affected, never on a value you read earlier.
  • Lock the critical section when logic is multi-step. SELECT ... FOR UPDATE inside a transaction, or a distributed lock (Redis), serializes concurrent requests so only one is inside at a time.
  • Enforce uniqueness with a constraint, not a check. A UNIQUE index turns "is this already used?" into a guarantee the database keeps under concurrency; a SELECT then INSERT races.
  • Add idempotency keys to mutating endpoints. They make a retried or duplicated request a no-op instead of a second action.

The sentence to keep: if your check and your action are two separate steps, two requests will slip between them — make the condition and the change one atomic operation.

See it happen in the race condition simulation: fire parallel withdrawals to drive a balance negative, then switch to a conditional atomic update and watch every extra request bounce. It's the same check-then-use gap that shows up in SSRF's DNS rebinding — concurrency and time are their own attack surface.

Share this post

Frequently Asked Questions

Related posts

Mass Assignment: How role=admin Ends Up in Your Update

Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.

Aug 10, 2026 · 6 min read

Insecure Deserialization: How Loading Data Becomes Running Code

Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.

Aug 9, 2026 · 7 min read

WebSocket Security: Why the Same-Origin Rules Don't Apply

WebSockets skip the protections you rely on for normal requests — CORS doesn't apply and cookies are sent automatically, enabling cross-site hijacking. Here's how to validate origins, authenticate connections, and secure real-time apps.

Aug 2, 2026 · 6 min read