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 principles
- Fold the check into the write.
WHERE balance >= :amount(orWHERE 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 UPDATEinside 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
UNIQUEindex turns "is this already used?" into a guarantee the database keeps under concurrency; aSELECTthenINSERTraces. - 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.
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 readInsecure 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 readWebSocket 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