CWE-367: Time-of-check Time-of-use Race Condition - JavaScript
Overview
A single-threaded event loop is often read as a guarantee that races cannot
happen. It rules out one kind - two lines of synchronous code cannot interleave -
and leaves the kind that matters here. Every await is a yield: the handler
suspends, other requests run to completion, and the state the code checked
before the await may be different when it resumes.
So in Node the rule is mechanical. Any authorization check, balance check, or
existence check followed by an await before its use is a time-of-check
time-of-use window, and the window is as long as the awaited operation.
Common Vulnerable Patterns
Token validated once, used after an await
// VULNERABLE - the token is checked, then the transfer runs on a stale decision
app.post('/api/transfer', async (req, res) => {
const user = await validateToken(req.headers.authorization);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
// The handler yields here; a revocation or account freeze can land
const prepared = await prepareTransfer(req.body);
const result = await bankService.transfer({
from: user.accountId,
to: prepared.toAccount,
amount: prepared.amount,
});
res.json(result);
});
Why this is vulnerable: user is a snapshot from before the awaits. If the
token is revoked or the account frozen while prepareTransfer is in flight, the
transfer still executes on the authorization that was true earlier. The larger
the awaited work, the wider the window - and unlike a thread race, this one does
not need luck, only a request that takes long enough.
Read-modify-write across an await
// VULNERABLE - two requests both read 100 before either writes
async function withdraw(accountId, amount) {
const account = await db.accounts.findById(accountId);
if (account.balance < amount) {
throw new Error('Insufficient funds');
}
await db.accounts.update(accountId, { balance: account.balance - amount });
}
Why this is vulnerable: The new balance is computed in application code from
a value read before a yield. Two concurrent calls both read 100, both pass the
check, and both write 0 - the account has paid out twice. The event loop
guarantees each line runs to completion; it guarantees nothing across the two
awaits.
Secure Patterns
Re-validate authorization at the point of use
// SECURE - the decision is made against current state, immediately before acting
app.post('/api/transfer', async (req, res) => {
const token = req.headers.authorization;
const user = await validateToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
const prepared = await prepareTransfer(req.body);
// Re-check immediately before the privileged step; do not reuse `user`
const current = await validateToken(token);
if (!current || current.accountStatus !== 'active') {
return res.status(401).json({ error: 'Authorization no longer valid' });
}
const result = await bankService.transfer({
from: current.accountId,
to: prepared.toAccount,
amount: prepared.amount,
idempotencyKey: req.headers['idempotency-key'],
});
res.json(result);
});
Why this works: The authorization used for the transfer is read after all
the slow work, so the window between decision and action is the transfer call
itself rather than the whole request. Acting on current rather than the
original user is the part that matters - re-validating and then using the
stale object is a common half-fix that reads as correct. The idempotency key
covers the adjacent problem: a client retry, or a duplicate request the attacker
sends deliberately, is collapsed into one transfer by the service rather than
being executed twice.
Narrowing the window is not closing it. Where the operation must be exactly correct, the authoritative check belongs inside the same database transaction as the write, as below.
Let the database evaluate the condition
// SECURE - condition and update are one statement, evaluated under the row lock
async function withdraw(accountId, amount) {
const { rowCount } = await pool.query(
`UPDATE accounts
SET balance = balance - $1
WHERE id = $2
AND balance >= $1`,
[amount, accountId],
);
if (rowCount === 0) {
throw new Error('Insufficient funds');
}
}
Why this works: balance >= $1 is evaluated by the database while it holds
the lock it needs to write the row, so no interleaving exists in which both
callers pass. rowCount reports the outcome at write time rather than at
read time, which is what makes the guard trustworthy. It does not say which
part of the predicate failed: zero means no row matched id = $2 AND balance >=
$1, so either the balance was too low or the account does not exist. Where the
caller has to tell those apart, follow the failed update with a SELECT for the
id - nothing was written, so there is no state to undo. This also survives
horizontal scaling: the guarantee lives in the database, not in one Node
process.
For multi-statement work, wrap the read and write in a transaction and take
SELECT ... FOR UPDATE on the row. An in-process mutex (async-mutex and
similar) only coordinates one process, and Node deployments almost always run
one process per core behind a load balancer.
Considerations
- Locate the
awaitbetween check and use. That is the whole analysis in Node, and it makes triage quick: noawaitin the gap means no window, and a network or database call in the gap means a wide one. A finding whose check and use sit in the same synchronous block is a false positive - record it as one. - How much the window costs. Re-validating before the action is enough for session revocation, where losing a narrow race is tolerable. It is not enough for balances, inventory or unique-slot allocation, where the invariant must hold exactly - those belong in a single database statement or transaction.
- Cluster mode and serverless remove in-process guarantees entirely. Under
cluster, PM2, or a Lambda-style runtime, module-level state is per instance. Any fix built on a module-scopedMap, counter, or mutex is scoped to one instance, and horizontal scaling silently reopens the race. - Idempotency is the practical companion control. Even a correctly locked operation can be submitted twice. An idempotency key turns a duplicate into a no-op, which is usually what the reported impact actually required.
Testing
The handler looks the same before and after; only concurrent execution distinguishes them.
- Fire the request concurrently with
Promise.allagainst a balance that admits exactly one success. Assert one success, one explicit failure, and a balance that is never negative. Run it a few hundred times - a single pass proves nothing about a race. - Revoke the token while a request is mid-flight (stub the slow step to hold open, or use a large payload) and assert the operation is refused rather than completing on the earlier decision.
- Send the same request twice with one idempotency key and assert exactly one transfer is recorded.
- Run the concurrency tests against more than one instance (
clusterwith two workers, or two processes behind the same database). This is what catches a fix that relies on in-process state. - Assert legitimate concurrency still works: two withdrawals that both fit within the balance should both succeed, rather than one being rejected by an over-broad lock.