Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix insecure randomness for reset tokens in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Generate the token with a CSPRNG: crypto.randomBytes(32).toString('base64url') is 256 bits (prefer it over crypto.randomUUID, which carries only 122). Never seed Math.random or a copied PRNG with the clock, and never 'fix' a weak token by hashing it. Compare the stored token in constant time with crypto.timingSafeEqual, and keep it single-use with a short expiry.

On this page

Steps

  1. Find every security token built from Math.random(), a copied PRNG (mulberry32, xmur3, an LCG) or Date.now(): reset tokens, session IDs, API keys, 'unguessable' URLs.
  2. Replace the generator with crypto.randomBytes(32).toString('base64url') (256 bits). crypto.randomUUID() is also CSPRNG-backed but carries only 122 bits, below the 128-bit floor, so prefer randomBytes for tokens.
  3. Delete any step that seeds a PRNG with the clock or hashes a timestamp; a hash adds no entropy, so SHA-256(Date.now()) is as guessable as Date.now().
  4. Store only a hash of the token, give it a short expiry, and mark it used on first success so a replayed token fails.
  5. Compare the presented token with crypto.timingSafeEqual over fixed-length digests, never === or a string compare.
  6. Add regression tests that reproduce a time-seeded PRNG over the issue-time window and brute-force SHA-256 of the clock, and assert neither reproduces the issued token.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
// mulberry32 is a widely pasted 32-bit PRNG. Seeding it with Date.now() gives only as much
// entropy as the uncertainty in the issue time -- far below the 128 bits the 16-byte length
// suggests. The whole stream is a function of one millisecond value.
function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return (t ^ (t >>> 14)) >>> 0;
  };
}

export function generateToken() {
  const next = mulberry32(Date.now());
  const bytes = Buffer.alloc(16);
  for (let i = 0; i < 16; i += 4) bytes.writeUInt32LE(next(), i);
  return bytes.toString('hex');
}

With IMPL=vulnerable, 3 of 10 tests fail (3 exploit tests).

The fix: generate from a CSPRNG

Fixedimpl/fixed.jsJavaScript
// crypto.randomBytes draws from the OS CSPRNG. 32 bytes is 256 bits, well above the 128-bit
// floor for a security token. (crypto.randomUUID() is also CSPRNG-backed, but a v4 UUID
// carries only 122 bits after its version and variant bits are fixed, so prefer randomBytes
// for tokens.) base64url keeps it URL-safe for a reset link.
export function generateToken() {
  return randomBytes(32).toString('base64url');
}

With IMPL=fixed, 10 of 10 tests pass.

The fix: constant-time compare

Fixedimpl/fixed.jsJavaScript
// Constant-time comparison (CWE-208). Hashing both sides to a fixed 32 bytes first means a
// token of the wrong length cannot make timingSafeEqual throw a RangeError and cannot leak
// the stored length through timing.
export function compareToken(presented, stored) {
  const a = createHash('sha256').update(String(presented)).digest();
  const b = createHash('sha256').update(String(stored)).digest();
  return timingSafeEqual(a, b);
}

With IMPL=fixed, 10 of 10 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
export function generateToken() {
  const material = String(Date.now());
  return createHash('sha256').update(material).digest('hex');
}

With IMPL=wrong, 2 of 10 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/resets.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  test('the victim token cannot be reproduced by replaying a time-seeded PRNG', async () => {
    const from = Date.now();
    const { body } = await requestReset(VICTIM);
    const to = Date.now();
    const predicted = new Set(prngTokensForWindow(from, to));
    assert.ok(!predicted.has(body.token), 'the issued token was reproduced from the clock alone');
  });

  test('the victim token cannot be recovered by hashing the issue time', async () => {
    const from = Date.now();
    const { body } = await requestReset(VICTIM);
    const to = Date.now();
    const candidates = new Set(hashedClockTokensForWindow(from, to));
    assert.ok(!candidates.has(body.token), 'the issued token was just SHA-256 of the clock');
  });

  test('the victim account cannot be taken over with a forged reset token', async () => {
    const from = Date.now();
    await requestReset(VICTIM); // the attacker triggers the victim's reset and notes the time
    const to = Date.now();
    const forged = [...prngTokensForWindow(from, to), ...hashedClockTokensForWindow(from, to)];
    let tookOver = false;
    for (const token of forged) {
      if ((await confirmReset(VICTIM, token)).status === 200) {
        tookOver = true;
        break;
      }
    }
    assert.ok(!tookOver, 'a forged token reset the victim account');
  });

  test('the reset token comparison runs in constant time', async (t) => {
    const spy = t.mock.method(crypto, 'timingSafeEqual');
    syncBuiltinESMExports(); // so `import { timingSafeEqual } from 'node:crypto'` sees the spy
    t.after(() => {
      spy.mock.restore();
      syncBuiltinESMExports();
    });
    const { body } = await requestReset(VICTIM);
    await confirmReset(VICTIM, body.token);
    assert.ok(spy.mock.callCount() > 0, 'compareToken did not use crypto.timingSafeEqual');
  });
});

Pitfalls

  • crypto.randomUUID() is unpredictable but only 122 bits (the version and variant bits are fixed), which ASVS 11.5.1 flags as short of 128; use randomBytes(32) when you want a token, and keep UUIDs for identifiers.
  • Math.random() is not seedable and not a CSPRNG; V8's xorshift128+ output can be reconstructed from a run of values. Never use it for anything secret, and do not reach for a seedable third-party PRNG instead -- seed material is the problem.
  • crypto.timingSafeEqual(a, b) throws a RangeError when the buffers differ in length, which itself leaks length; hash both sides to a fixed 32 bytes first (as impl/fixed.js does) or length-check in constant time.
  • A token is only as unguessable as its weakest input. Mixing a counter or a username into a weak base does not help; start from CSPRNG bytes.
  • Returning or logging the token, or putting it in a URL that lands in Referer or server logs, hands it to anyone who reads those. This card returns it only to model the reset link the test reads.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs insecure-randomness/node-express does this for you).
  2. Against your own app, request a reset for an account you control, confirm the token is 43 URL-safe base64 characters (32 bytes), request several and confirm they share no structure, and check the lookup rejects a token of the wrong length without an error.
  3. Scanners miss this one in Node: Semgrep's default pack found nothing because the code never calls Math.random(), and CodeQL's js/insecure-randomness keys on sources it models. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix insecure randomness for reset tokens in Node.js and Express
ToolRuleWhat it found here
Semgrepp/defaultNo findings on any implementation. Semgrep's JavaScript weak-randomness rules key on a literal Math.random() call; this card's vulnerable code uses a copied mulberry32 PRNG seeded with Date.now() and never calls Math.random(), so nothing matches. A static rule that recognised only Math.random() would miss this bug.Checked: Semgrep 1.180.0 (CE)
CodeQLjs/insecure-randomnessA path-problem query that reports a flow from a non-cryptographic random source (such as Math.random()) to a security-sensitive sink. Whether it flags this card depends on its source model recognising a hand-rolled PRNG seeded with Date.now(); the standard model keys on Math.random(), which this card does not use, so a finding is unlikely.Checked: github/codeql main; read from source, not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
node:crypto (built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the insecure-randomness/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs insecure-randomness/node-express runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Machine
Apple M5 Max, macOS 27.0
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • express 5.3.0
Matrix
fixed: 10 of 10 tests pass; vulnerable: 3 of 10 tests fail (3 exploit tests); wrong: 2 of 10 tests fail (2 exploit tests)
Test results
Offline run of run-all.mjs (outbound network blocked): fixed 10/10 pass; vulnerable 3/10 fail (3 exploit); wrong 2/10 fail (2 exploit)
Dependency freshness
express 5.3.0 was published 2026-10-09; it is taken at one day old because it is a security release (qs, body-parser and proxy-addr advisories). npm audit reported 0 vulnerabilities. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Node.js v26 crypto.randomBytes()
  2. Node.js v26 crypto.randomUUID() (122 bits of entropy)
  3. Node.js v26 crypto.timingSafeEqual()
  4. MDN: Math.random() is not cryptographically secure
  5. OWASP ASVS 5.0.0 V11.5 Random Values (11.5.1)
  6. CWE-338: Use of Cryptographically Weak PRNG
  7. CAPEC-59: Session Credential Falsification through Prediction

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.