- Concurrency
- Databases
- Security
Race Conditions and TOCTOU Bugs in Web Applications
How double-spends, coupon reuse and limit bypasses happen when requests race, and how to fix them with constraints, atomic updates, locks and idempotency keys.
· 7 min read · Lina Source LLC
Most web code is written as if requests arrive one at a time. Read the balance, check it is high enough, subtract, save. Tested by hand, it works every time. Sent twenty times in parallel, it can spend the same money twenty times.
These bugs are race conditions (CWE-362), and the most common shape is time-of-check to time-of-use, or TOCTOU (CWE-367): the application checks a condition, then acts on it, and the condition changes in between. They are easy to miss in review because every line looks correct on its own. The bug is in the gap between two lines.
Why Node.js is not immune
A common belief is that single-threaded runtimes cannot have race conditions. JavaScript runs one callback at a time, but every await is a point where another request can run. Your database is shared by all instances, all workers and all requests. Between the SELECT and the UPDATE, anything can happen.
// Vulnerable: check-then-act across two awaits
export async function withdraw(userId: string, amount: number) {
const account = await db.account.findUnique({ where: { userId } });
if (!account || account.balance < amount) {
throw new Error('Insufficient funds');
}
// Another request can pass the same check before this line runs
await db.account.update({
where: { userId },
data: { balance: account.balance - amount },
});
}Two requests read a balance of 100, both pass the check for a withdrawal of 100, and both write 0. The user got 200 out of an account holding 100. Because the second write overwrites the first with a value computed from stale data, the logs show nothing unusual. ORMs do not change this. Loading a record into an object, modifying the object and saving it is the same read-modify-write pattern, with the same gap.
Where races show up
- Balances, credits and wallets: double-spending the same funds.
- Coupons and gift cards: redeeming a single-use code several times.
- Plan and usage limits: creating more projects, seats or API calls than the plan allows.
- Sign-up and invitations: creating two accounts with the same email, or accepting one invite twice.
- Voting, likes and ratings: counting one user more than once.
- Payment and order flows: fulfilling an order twice when a webhook and a redirect arrive together.
Exploiting them is not hard. Sending a batch of requests at once with Promise.all, curl or a proxy tool is enough to hit windows of a few milliseconds, and techniques like the single-packet attack make the requests arrive at the server almost simultaneously. Assume that if a race exists, someone can win it.
Fix 1: let the database enforce the rule
The strongest fixes move the rule into the database, where concurrent requests are serialized for you. Two tools cover most cases: unique constraints and atomic conditional updates.
-- Single-use per user: the second insert fails, no matter the timing
CREATE UNIQUE INDEX coupon_redemptions_once
ON coupon_redemptions (coupon_id, user_id);
-- Global usage cap: check and increment in one statement
UPDATE coupons
SET uses = uses + 1
WHERE id = $1
AND uses < max_uses
RETURNING id;
-- Balance: the condition is evaluated against the current row
UPDATE accounts
SET balance = balance - $1
WHERE user_id = $2
AND balance >= $1
RETURNING balance;
-- Belt and braces: the balance can never go negative
ALTER TABLE accounts
ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);The conditional UPDATE works because the database locks the row while it evaluates the WHERE clause. If two requests race, the second one re-checks the condition against the row the first one committed. If no row comes back, the condition failed and you return an error. There is no gap to exploit because the check and the write are the same statement.
In application code, treat the unique violation as a normal outcome. In PostgreSQL it arrives as error code 23505; map it to a clear message such as 'coupon already used' instead of a 500.
Most ORMs can express the conditional update. With Prisma, updateMany with the balance condition in its where clause returns a count, and a count of zero means the check failed. A findUnique followed by a separate update does not give you the same guarantee, however carefully the check is written.
Fix 2: lock the row with SELECT ... FOR UPDATE
Sometimes the decision needs more than one statement: read several fields, call a pricing function, write to two tables. Then take a row lock inside a transaction. SELECT ... FOR UPDATE makes any other transaction that tries to lock the same row wait until yours commits or rolls back.
A transaction alone is not enough. PostgreSQL's default isolation level is READ COMMITTED, and at that level wrapping the vulnerable code from earlier in BEGIN and COMMIT changes nothing: both transactions read the same balance, both pass the check and both writes succeed. The lock is what forces the second transaction to wait and then read the committed value.
import { Pool } from 'pg';
const pool = new Pool();
export async function purchase(userId: string, itemId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
[userId],
);
const item = await client.query(
'SELECT price FROM items WHERE id = $1',
[itemId],
);
if (rows.length === 0 || item.rows.length === 0) {
throw new Error('Not found');
}
const price = Number(item.rows[0].price);
if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[price, userId],
);
await client.query(
'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
[userId, itemId, price],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}Two details matter. The lock only helps if every code path that changes the balance also takes it; one path that updates without locking reopens the race. And the whole sequence must use the same client: running BEGIN on one pooled connection and the SELECT on another gives you no transaction at all. ORMs offer the same pattern through interactive transactions or raw queries.
Fix 3: advisory locks for rules that span rows
Row locks do not help when the rule is about rows that do not exist yet, such as 'a free-plan user may have at most three projects'. Two requests can each count two projects and each insert a third. PostgreSQL advisory locks let you lock an arbitrary key, such as the user ID, for the duration of a transaction.
Inside the transaction, call pg_advisory_xact_lock with a key derived from the user, then count and insert. The function takes a 64-bit integer key, so derive a stable integer from the user ID, for example with hashtext; an occasional collision between unrelated users only costs a little waiting, never correctness. The lock is released automatically at commit or rollback. Another option is SERIALIZABLE isolation, which makes PostgreSQL detect the conflicting transactions and abort one with error 40001; that works well, provided your code retries aborted transactions.
What does not work is an in-memory mutex or a JavaScript Map of locks. It covers one process. The moment you run two instances, two serverless functions or a background worker, the lock is gone.
Fix 4: idempotency keys for retries and double submits
Some duplicates are not attacks: a user double-clicks, a mobile client retries after a timeout, a payment provider redelivers a webhook. An idempotency key turns 'do this' into 'do this once'. The client generates a key per logical operation, and the server records it with a unique constraint before doing the work.
-- Schema
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
user_id uuid NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Claim the key first; zero rows returned means another request owns it
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;If the insert returns a row, run the operation in the same transaction and store the response. If it returns nothing, look up the stored response and return it, or return 409 if the first request is still in progress. Scope keys to the user so one user cannot replay or block another's key, and expire them after a sensible window. For webhooks, the provider's event ID is the natural key. Consider storing a hash of the request body with the key and rejecting reuse of a key with a different body, so a client bug cannot silently receive the result of another operation.
Finding races in your code
- Look for a read followed by a write of the same data with an await in between.
- Look for counts compared against limits before an insert.
- Look for 'find, then create if missing' without a unique constraint behind it.
- Check that every write to a sensitive value goes through the same locked or atomic path.
- Write a test that fires the same request concurrently and asserts the invariant still holds.
The concurrent test is the most convincing evidence. Run the operation twenty times with Promise.all against a real database, then assert the balance, the redemption count or the number of rows. If it fails before the fix and passes after, the race is closed. Run it more than once; a race that fails one run in five is still a race.
Race conditions are the kind of bug an AI reviewer can surface by reading the whole flow rather than a single line. When CodeAuditAgent flags one, the finding carries the CWE, the quoted check and write, an exploit scenario describing the parallel requests, and a patch, usually one of the fixes above. The general rule is the same either way: let the database decide, because it is the one component that sees every request.