Race Conditions and Business Logic Flaws in Node.js: Atomic Endpoints and TOCTOU Defenses [2026]
The Vulnerability Class That Never Shows Up in Scans
DAST scanners flag XSS, dependency audits flag CVEs, and SAST flags unsafe patterns. Business logic flaws show up in none of them. They surface later, as chargebacks, support tickets about a coupon that "worked twice", negative inventory in the warehouse system, or a user whose balance went below zero without anyone understanding how.
OWASP's API Security Top 10 has called this out explicitly: API6 — Unrestricted Access to Sensitive Business Flows covers abuse of flows like coupon redemption, referral bonuses, and free-trial credits. And the closely related class — race conditions (TOCTOU, time-of-check-to-time-of-use) — is the reason "correct" code still loses money. The code is semantically right for a single request. It's wrong under concurrency.
If you run a startup where money, credits, inventory, or single-use tokens cross your API, this article is about the bugs that will actually hurt you.
Single-Threaded Doesn't Mean Race-Free
Node.js runs JavaScript on a single thread, which confuses a lot of developers into thinking race conditions are impossible. There are no shared-memory data races between two synchronous operations — true. But every await is a scheduling point. While your handler awaits a database query, the event loop picks up another request and runs its handler up to its next await. Two requests interleave, and each one operates on stale data.
Here is the canonical check-then-act bug, a credit-spending endpoint:
// /api/credits/use — VULNERABLE
app.post("/api/credits/use", async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.userId } }); // await #1
if (user.balance < 100) {
return res.status(400).json({ error: "Insufficient credits" });
}
await db.user.update({
where: { id: req.userId },
data: { balance: user.balance - 100 }, // await #2 — writes a STALE value
});
res.json({ ok: true });
});
Trace two concurrent requests for a user with balance 150:
- Request A reads
balance = 150(await #1), passes the check. - Request B reads
balance = 150(await #1), passes the check — A hasn't written yet. - Request A writes
150 - 100 = 50. - Request B writes
150 - 100 = 50— overwriting A's write.
The user just spent 200 credits while owning 150, and the final balance says 50. An attacker who can fire requests in parallel (a single Promise.all in a script is enough) can repeat this until the account is negative. This is TOCTOU: the check happens against a value that is already stale by the time the write lands.
The pattern is everywhere: stock checks, seat availability, coupon redemption, referral credits, free-trial upgrades, webhook processing. Same shape, different domain.
The Four Classic Races
1. Double-Spend: Balances and Credits
The example above. The fix is to make the check and the write a single atomic statement — no read-then-write:
// FIXED — one conditional atomic update
const result = await db.user.updateMany({
where: { id: req.userId, balance: { gte: 100 } }, // the check, in the WHERE
data: { balance: { decrement: 100 } }, // the write, atomically
});
if (result.count === 0) {
return res.status(400).json({ error: "Insufficient credits" });
}
res.json({ ok: true });
The database evaluates the condition and performs the decrement as one operation. If two requests race, one of them gets count === 1, the other count === 0. No interleaving is possible because nothing is read before the write — the WHERE clause is the check, executed atomically with the mutation.
2. Inventory Oversell
// VULNERABLE — classic stock oversell
if (product.stock > 0) {
await db.product.update({
where: { id: productId },
data: { stock: product.stock - 1 },
});
// two concurrent buyers both see stock 1 → both buy → stock is now -1
}
Same shape, same fix — condition in the WHERE:
const result = await db.product.updateMany({
where: { id: productId, stock: { gte: 1 } },
data: { stock: { decrement: 1 } },
});
if (result.count === 0) return res.status(409).json({ error: "Out of stock" });
3. Single-Use Coupons and One-Time Codes
// VULNERABLE — double redemption
const coupon = await db.coupon.findUnique({ where: { code } });
if (coupon.usedBy) return res.status(400).json({ error: "Already used" });
await db.coupon.update({ where: { id: coupon.id }, data: { usedBy: userId } });
// two parallel requests both see usedBy = null → both apply the discount
Two layers of defense:
// Layer 1: conditional claim — only one request can win
const claimed = await db.coupon.updateMany({
where: { code, usedBy: null },
data: { usedBy: userId },
});
if (claimed.count === 0) return res.status(400).json({ error: "Already used" });
// Layer 2: unique constraint — the database refuses a second claim even if
// the application logic somehow races. This is the only guarantee that
// never loses a race.
ALTER TABLE coupons ADD CONSTRAINT coupons_used_by_unique
UNIQUE NULLS NOT DISTINCT (used_by);
-- PostgreSQL 15+: UNIQUE NULLS NOT DISTINCT makes NULLs conflict too,
-- so a second "unused" claim row is impossible.
Unique constraints are the final backstop: application logic can race, but the database cannot. When the constraint fires, catch the violation (PostgreSQL error code 23505) and return a clean 409.
4. Webhooks and Payment Idempotency
Stripe retries webhooks that fail. Axios retries requests that time out. Mobile apps retry charges when the connection drops. Every retry is a duplicate — and without idempotency, a duplicate webhook double-charges the customer or double-credits the subscription.
The standard design (the one Stripe itself uses): the client sends an Idempotency-Key header, and the server stores the response keyed by it. A retry with the same key gets the stored response instead of executing again:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
user_id UUID NOT NULL,
response JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
app.post("/api/payments/charge", async (req, res) => {
const idemKey = req.headers["idempotency-key"];
if (!idemKey) return res.status(400).json({ error: "Missing Idempotency-Key" });
// Claim the key INSIDE the same transaction as the charge — if the
// transaction commits, the key is claimed; if it rolls back, the key
// is free and the client can retry.
try {
const response = await db.$transaction(async (tx) => {
const claimed = await tx.$executeRaw`
INSERT INTO idempotency_keys (key, user_id, response)
VALUES (${idemKey}, ${req.userId}, '{}')
ON CONFLICT (key) DO NOTHING`;
if (claimed === 0) {
// Someone already processed this key — return the stored result
const existing = await tx.idempotencyKey.findUnique({ where: { key: idemKey } });
return existing.response;
}
const charge = await chargeCustomer(req.userId, req.body.amount); // external call
await tx.idempotencyKey.update({
where: { key: idemKey },
data: { response: charge },
});
return charge;
});
res.json(response);
} catch (err) {
// If the charge fails, roll back the key claim so the client may retry.
res.status(402).json({ error: "Charge failed" });
}
});
The ON CONFLICT (key) DO NOTHING plus the primary key on key is the atomic claim — exactly one concurrent request gets claimed === 1.
The Fix Toolkit, Ranked
When you find a check-then-act pattern, here's how to choose the defense:
1. Atomic conditional updates (prefer first)
One statement, condition in the WHERE, mutation in the SET. No transaction needed, no locks held, fastest in every benchmark. Works whenever the invariant is about a single row: balances, stock, status transitions, single-use claims.
2. Transactions with row locks (multi-row invariants)
When the invariant spans rows (transfer money from A to B and never let the sum go negative, or book a seat while updating the trip's remaining capacity), you need a transaction. In PostgreSQL, SELECT ... FOR UPDATE locks the rows so concurrent writers block until commit:
BEGIN;
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- Row locked: any other writer on this row waits until COMMIT
UPDATE accounts SET balance = balance - 100 WHERE id = $1;
COMMIT;
With Prisma, use an interactive transaction:
await prisma.$transaction(async (tx) => {
// Lock the row, then read the fresh value inside the lock
const [account] = await tx.$queryRaw`
SELECT id, balance FROM accounts WHERE id = ${accountId} FOR UPDATE`;
if (account.balance < 100) throw new InsufficientFundsError();
await tx.account.update({
where: { id: accountId },
data: { balance: { decrement: 100 } },
});
});
Two rules for transactions: keep them short, and never await an external HTTP call while holding locks — the lock is held for the entire round trip, and a slow third-party API becomes a pile-up of blocked requests.
3. Unique constraints (the backstop)
Anything with "single-use" or "one per user" semantics gets a unique index, and the app treats the violation as a normal control flow. The DB is the only component that never sleeps, never schedules, and never races.
4. Optimistic locking (long-lived documents)
For documents edited over time (profiles, carts, settings), guard with a version column:
UPDATE documents
SET body = $1, version = version + 1
WHERE id = $2 AND version = $3;
-- rowCount 0 → the document changed since you read it → return 409
The client sends the version it read; the update only lands if nothing changed in between. Cheap, no locks, and the 409 tells the client to re-fetch and retry.
5. Idempotency keys (client retries and webhooks)
Any endpoint a client or provider may call more than once — payments, webhook consumers, anything triggered by unreliable networks — takes an idempotency key and dedupes at the storage layer, as shown above.
6. Distributed locks (last resort)
Redis SET key value NX EX (or Redlock) serializes a critical section across instances. Use it only when database atomicity genuinely can't express the invariant (e.g., a multi-service workflow). Locks have failure modes of their own — a lock that expires mid-operation lets a second process in, and a lock that never expires deadlocks your queue. Prefer the database.
7. In-process serialization (single instance)
If you run a single Node process, a per-key promise queue serializes operations without any infrastructure:
const queues = new Map();
function serialize(key, fn) {
const prev = queues.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn); // run after the previous op, regardless of outcome
queues.set(key, next.catch(() => {})); // never cache a rejected promise
return next;
}
// Usage
app.post("/api/credits/use", (req, res) => {
serialize(req.userId, () => spendCredits(req.userId, 100))
.then((result) => res.json(result))
.catch(() => res.status(400).json({ error: "Insufficient credits" }));
});
This buys you nothing across multiple instances, so treat it as a development convenience, not a production strategy.
Business Logic Flaws Beyond Races
Race conditions are one flavor of business logic flaw. The other flavors are about validation that only exists on the client:
1. Trusting client-side totals
// VULNERABLE — price and amount come from the client
const { productId, quantity } = req.body;
const total = priceFromDb(productId) * quantity; // quantity = -1000 → negative total
Rules that belong on the server, always:
- Store money as integer cents, never floats —
0.1 + 0.2 !== 0.3is a rounding exploit vector at scale. - Compute totals server-side from server-stored prices. Never accept an amount, a discount, or a tax figure from the client.
- Bound every quantity:
quantitymust be an integer in[1, 99]. A negative quantity or a zero amount is a free-money bug. - Return 400 on validation failure, not a silent success. A request that "kind of worked" is an audit nightmare.
2. Unguarded state transitions
// VULNERABLE — any state can jump to any state
await db.order.update({ where: { id }, data: { status: "shipped" } });
// A concurrent refund + ship can leave an order both refunded and shipped
Model status as a state machine and encode the transition in the WHERE:
const r = await db.order.updateMany({
where: { id, status: "paid" }, // only paid orders may ship
data: { status: "shipped" },
});
if (r.count === 0) return res.status(409).json({ error: "Invalid transition" });
3. Mass assignment via request body
// VULNERABLE — user can set role: "admin"
await db.user.update({ where: { id }, data: req.body });
// FIXED — explicit allowlist
const { name, email } = req.body;
await db.user.update({
where: { id },
data: { name, email }, // role, balance, plan: rejected by omission
});
Never spread req.body into a model. Allowlist every field.
How to Find These in Your Codebase
Automated tools won't find business logic flaws. People, patterns, and load tests will:
- Grep for check-then-act shapes. Every
if (x.stock > 0),if (user.balance >=,if (!coupon.usedBy)followed by a write is a suspect. The tell is reading a row and later writing a value derived from that read. - Trace every
awaitbetween read and write. If there's anawaitbetween the check and the mutation, you have a TOCTOU window — close it with a conditional update. - Fire concurrent requests in tests. This is the cheapest high-value test you can write:
// 20 parallel redemptions of the same coupon — exactly 1 must succeed
const results = await Promise.all(
Array.from({ length: 20 }, () => redeem(couponCode, userId))
);
const successes = results.filter((r) => r.ok).length;
assert.strictEqual(successes, 1); // fails on the vulnerable implementation
- Repeat webhooks and duplicate keys in staging. Send the same Stripe event twice, the same idempotency key twice, and assert the side effect happens once.
Deployment Checklist
- [ ] Balance/stock/credit mutations use conditional atomic updates or row-locked transactions — no read-then-write
- [ ] Single-use semantics (coupons, OTPs, referral codes) backed by unique constraints, not app checks
- [ ] Conflicting mutations return 409, never silent success
- [ ] Payments and webhook consumers require and enforce
Idempotency-Key; stored responses are replayed - [ ] Money stored as integer cents; totals computed server-side; quantities bounded
- [ ] Status transitions guarded with
WHERE status = <previous> - [ ] No external network calls inside database transactions
- [ ] Concurrency test in CI for every money-touching endpoint (parallel request assertion)
- [ ] Version column (optimistic locking) on long-lived editable documents
- [ ]
req.bodynever spread into model updates — explicit allowlists only
Summary
-
Node.js race conditions are interleaving bugs at
awaitpoints, not memory races. Every read → business decision → write across anawaitis a TOCTOU candidate. -
Prefer atomic conditional updates. The condition goes in the
WHERE, the mutation in theSET— one statement, no locks, no window. -
Transactions with
SELECT ... FOR UPDATEfor multi-row invariants — and keep them short, with no external awaits inside. -
Unique constraints never lose a race. They are the only guarantee that survives application bugs.
-
Idempotency keys for anything retryable — payments, webhooks, mobile clients. Claim the key atomically with the work.
-
Business logic validation is server-side by definition: integer cents, server-computed totals, bounded quantities, guarded state machines, allowlisted fields.
Next week: Dependency Confusion and Typosquatting — how attackers hijack npm install with internal package names and typo'd spellings, and the registry, CI, and lockfile defenses that keep them out of your build.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.