Skip to content

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 await between check and use. That is the whole analysis in Node, and it makes triage quick: no await in 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-scoped Map, 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.all against 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 (cluster with 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.

Additional Resources