Safeguard
Industry Analysis

Secure Random Number Generation in JavaScript with crypto...

Math.random() is predictable and unsafe for security tokens. Here's why Node's crypto.randomBytes() is the standard for secure JavaScript randomness.

Aman Khan
AppSec Engineer
7 min read

A password reset token generated with Math.random(), a session ID built from Date.now(), an API key seeded from a timestamp — these show up in production JavaScript far more often than they should. Math.random() is fast and convenient, but it was never designed to resist prediction: V8's implementation (xorshift128+) and similar engines in other runtimes optimize for statistical distribution in simulations and games, not for hiding internal state from an attacker. Security researchers have repeatedly demonstrated that observing a small number of consecutive outputs is enough to reconstruct the generator's internal state and predict every value it will produce afterward. Node.js has shipped a purpose-built alternative since 2011: crypto.randomBytes(), a cryptographically secure pseudorandom number generator (CSPRNG) built on the same primitives used for TLS keys and digital signatures. This piece breaks down why Math.random() fails for security-sensitive code, how crypto.randomBytes() closes the gap, and how to use it correctly across Node.js and the browser.

Why isn't Math.random() safe for tokens, keys, or session IDs?

Because it's a general-purpose pseudorandom number generator (PRNG), not a cryptographically secure one, and its internal state can be recovered from its output. V8 (the JavaScript engine behind Node.js and Chrome) has used xorshift128+ for Math.random() since 2015, replacing an even weaker MWC1616 algorithm that carried only 32 bits of usable state. Xorshift128+ is excellent for statistical uniformity — it passes benchmarks like TestU01 — but it is explicitly not designed to be unpredictable to an adversary. Researchers, including the widely cited v8_rand_buster proof-of-concept, have shown that capturing as few as four to six consecutive Math.random() outputs is sufficient to reverse-engineer the generator's 128-bit state and predict all subsequent values with full accuracy. CWE-338 ("Use of Cryptographically Weak Pseudo-Random Number Generator") exists specifically to classify this failure mode, and OWASP's Application Security Verification Standard (ASVS 6.3.1) requires cryptographic-strength randomness for any session identifier, token, or key. If a value gates authentication, authorization, or account recovery, Math.random() is disqualified by design, not by misconfiguration.

What does crypto.randomBytes() actually do differently?

It draws directly from the operating system's cryptographically secure entropy source instead of a fast, stateful PRNG optimized for speed. On Linux, crypto.randomBytes() reads from /dev/urandom (or the getrandom() syscall on kernels 3.17+); on Windows it calls BCryptGenRandom; on macOS it uses CCRandomGenerateBytes. These sources are fed by hardware and system entropy — interrupt timing, thermal noise, and dedicated hardware RNGs on modern CPUs — and are the same primitives OpenSSL and the OS kernel use to generate TLS session keys. Node.js has exposed this via the crypto module since version 0.5.8, over 15 years of production hardening. Because the output isn't derived from a small, invertible internal state like xorshift128+, there's no known practical way to predict future bytes from past ones. The Node.js documentation itself states this plainly: it explicitly warns that Math.random() "is not a cryptographically secure random number generator" and directs developers to crypto.randomBytes() for anything security-related — advice baked directly into the platform's own docs, not a third-party opinion.

How do you generate secure tokens, keys, and IDs with crypto.randomBytes() in Node.js?

You call it with the number of bytes you need and encode the output as hex or base64, and Node.js has added several higher-level helpers on top of it to reduce misuse. A typical secure token looks like this:

const crypto = require('crypto');

// Async (preferred — non-blocking)
crypto.randomBytes(32, (err, buf) => {
  if (err) throw err;
  const token = buf.toString('hex'); // 64-char hex string, 256 bits of entropy
});

// Sync form (fine for small sizes, startup-time use)
const apiKey = crypto.randomBytes(24).toString('base64url');

Thirty-two bytes (256 bits) is a common choice for session tokens and API keys, well above the 128-bit minimum most security standards require. For generating unbiased random integers in a range — historically a common bug source when developers did crypto.randomBytes(1)[0] % 100, which skews low values due to modulo bias — Node.js 14.10.0 (August 2020) added crypto.randomInt(min, max), which rejects out-of-range samples instead of wrapping them. And since Node.js 14.17.0 / 15.6.0 (2021), crypto.randomUUID() generates RFC 4122 version 4 UUIDs (122 bits of real randomness after fixed version/variant bits) directly from the CSPRNG with no extra dependency required — which is also why the popular uuid npm package switched its v4 generator to wrap the platform's CSPRNG rather than Math.random().

Does the browser have an equivalent, and how does it differ from Node's crypto module?

Yes — the Web Crypto API's crypto.getRandomValues(), standardized by the W3C and supported in every evergreen browser since roughly 2014, fills a typed array with CSPRNG output sourced from the OS in the same way Node's module does. The core difference is API shape, not security guarantee: getRandomValues() operates on TypedArray buffers you allocate (Uint8Array, Uint32Array, etc.) rather than returning a Buffer, and the spec caps a single call at 65,536 bytes — request more and it throws a QuotaExceededError, which is a real, spec-defined limit worth knowing if you're generating large blobs client-side.

const array = new Uint8Array(32);
crypto.getRandomValues(array);
const token = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');

Both window.crypto.randomUUID() (browser) and crypto.randomUUID() (Node) now exist as convenience wrappers, so isomorphic code targeting both environments can standardize on the same call pattern in most modern runtimes (Node.js 19+ exposes the global crypto object without requiring require('crypto'), mirroring the browser).

What happens when developers get this wrong in production?

Weak randomness has caused real financial losses, and the pattern repeats because the mistake is easy to make and hard to spot in review. In September 2022, the vanity Ethereum address generator Profanity was found to seed its address search with a 32-bit value instead of full cryptographic entropy, an insufficient-randomness flaw that attackers exploited to brute-force private keys for addresses generated with the tool — contributing to roughly $160 million in losses at the market maker Wintermute alone. That incident wasn't JavaScript-specific, but it illustrates exactly the class of bug CWE-338 describes, and static analysis tooling (ESLint's security/detect-pseudoRandomBytes rule, for instance) exists precisely because Math.random() shows up in password-reset tokens, CSRF tokens, and API key generators inside real npm packages with enough regularity that it's treated as a standing category of finding rather than a one-off mistake. Because these tokens are often generated deep inside a transitive dependency — a session middleware, a token library, an internal utility package — the weakness frequently isn't visible from the call site that ultimately relies on it being unpredictable.

How Safeguard Helps

This is a software supply chain problem as much as a coding-standards problem: the vulnerable Math.random() call is rarely in your own code, it's in a dependency three levels deep that a session library, CSRF middleware, or internal SDK pulled in years ago and never revisited. Safeguard's software composition analysis scans your full dependency graph — direct and transitive — for known weak-randomness patterns (CWE-338) and flags packages calling Math.random() or nonce-generation logic in security-relevant contexts, mapped against CVE and advisory databases as they're disclosed. Beyond detection, Safeguard's SBOM-driven visibility means that when a new advisory lands for a package generating tokens, keys, or session identifiers, you can immediately see every service and build artifact that pulls it in, rather than grepping repositories under deadline pressure. For teams enforcing internal secure-coding standards, Safeguard's policy engine can gate builds on static findings like insecure randomness before they reach production, turning "we should really use crypto.randomBytes() everywhere" from an aspiration into an enforced control across every repository in the organization.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.