The TL;DR
Math.random() is fine for
- arrow_rightAnimations and procedural visuals
- arrow_rightGame mechanics with no real-world stake
- arrow_rightSampling and load-testing data
- arrow_rightAnything where prediction has zero value to anyone
Use Web Crypto for
- arrow_rightPasswords, API keys, session tokens
- arrow_rightEncryption keys, IVs, nonces
- arrow_rightCSRF tokens, password reset links
- arrow_rightLottery picks, raffles, anything with a payout
Why Math.random() Is Predictable
Math.random() in V8 (Node.js, Chromium) uses an xorshift128+ generator. Firefox uses the same algorithm, Safari uses a similar variant. These are all pseudo-random number generators — fully deterministic functions of an internal state, seeded once at engine startup from system entropy.
In 2018, Mike Malone demonstrated that an attacker who observes ~5 consecutive outputs of Math.random() can reconstruct V8's internal state with a few seconds of Z3 SMT solving and predict every future output. The attack works because xorshift128+ has only 128 bits of state and a publicly known transition function.
This matters when an attacker can see any output of the same generator the secret depends on. A web app that sends a random session ID to the user's browser, then derives the next user's ID with Math.random() from the same engine, is exposing predictable secrets even though the secret itself never appears in the output stream.
The Web Crypto API
Every modern browser, Node.js 19+, Deno, Bun, and Cloudflare Workers ship a CSPRNG behind the W3C Web Crypto API. The two relevant entry points are:
// Fill a typed array with random bytes (max 65,536 bytes per call)
const buf = new Uint8Array(32);
crypto.getRandomValues(buf);
// buf is now 32 cryptographically random bytes
// Generate a random RFC 4122 v4 UUID
const id = crypto.randomUUID();
// "3f6a4b1c-9c2e-4d8a-b9f1-72e3a5b6d4e7"
// For longer needs, call repeatedly
function randomBytes(n) {
const out = new Uint8Array(n);
for (let i = 0; i < n; i += 65536) {
crypto.getRandomValues(out.subarray(i, Math.min(i + 65536, n)));
}
return out;
}Under the hood, the browser pulls from the OS CSPRNG: /dev/urandom on Linux/macOS, BCryptGenRandom on Windows. These are seeded continuously from hardware entropy sources and are designed for exactly this use case. Performance is excellent — typically tens of millions of bytes per second.
The Modulo Bias Trap
Suppose you want a random integer in [0, 10) from a random byte. The naïve code is:
// BIASED — do not use
function biasedRandomInt(max) {
const buf = new Uint8Array(1);
crypto.getRandomValues(buf);
return buf[0] % max;
}The bug: 256 is not divisible by 10. Bytes 0–249 map evenly to 0–9 (25 bytes per outcome), but bytes 250–255 add an extra count to outcomes 0–5. The result: 0–5 are about 4% more likely than 6–9. Over millions of draws this is statistically obvious, and over a single password generation it can subtly weaken security.
The bias is small for the byte-mod-10 case but explodes if you take it seriously. Picking a random integer in [0, 200) from a single byte gives outcomes 0–55 a 50% higher probability than 56–199.
Unbiased Random Integers
The fix is rejection sampling: discard any random value that falls in the "leftover" region above the largest multiple of max that fits in the source range, then take it modulo. Re-roll the discards.
// Unbiased random integer in [0, max)
function randomInt(max) {
if (max <= 0 || max > 2 ** 32) {
throw new RangeError('max must be in (0, 2^32]');
}
const buf = new Uint32Array(1);
// Largest multiple of max that fits in 2^32
const limit = Math.floor(2 ** 32 / max) * max;
let n;
do {
crypto.getRandomValues(buf);
n = buf[0];
} while (n >= limit);
return n % max;
}
// Random in inclusive [min, max]
function randomIntRange(min, max) {
return min + randomInt(max - min + 1);
}
// Roll a die 1..6
randomIntRange(1, 6);
// Pick a random array element, unbiased
function pick(arr) {
return arr[randomInt(arr.length)];
}Worst-case rejection probability is just under 50% (when max is just over a power of two), so this is at most twice as slow on average — and almost always faster than a single network round trip.
When to Use Each
| Use case | API |
|---|---|
| Particle effects, jitter, animation | Math.random() |
| Procedural map generation in a game | Seeded PRNG (e.g. seedrandom) |
| Randomly sampling rows for analytics | Math.random() |
| A/B test bucket assignment | Hash of stable user ID |
| Session ID, CSRF token, password reset link | crypto.getRandomValues |
| Generated password, API key, secret | crypto.getRandomValues |
| UUID | crypto.randomUUID() |
| Symmetric encryption key, IV, nonce | crypto.getRandomValues |
Server-Side Equivalents
Every server-side language ships a CSPRNG. Use it; do not write your own:
// Node.js
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
randomBytes(32); // Buffer of 32 random bytes
randomInt(0, 100); // unbiased int in [0, 100)
randomUUID(); // RFC 4122 v4 UUID
// Python
import secrets
secrets.token_bytes(32) # 32 random bytes
secrets.token_hex(16) # 32-char hex string
secrets.token_urlsafe(32) # URL-safe base64 string
secrets.choice(items) # unbiased pick
secrets.randbelow(100) # unbiased int in [0, 100)
# Go
import "crypto/rand"
import "math/big"
buf := make([]byte, 32)
rand.Read(buf)
n, _ := rand.Int(rand.Reader, big.NewInt(100))
// Rust
use rand::rngs::OsRng;
use rand::RngCore;
let mut buf = [0u8; 32];
OsRng.fill_bytes(&mut buf);
// Ruby
require 'securerandom'
SecureRandom.hex(16)
SecureRandom.uuid
SecureRandom.random_number(100)Note that Python's old random module and Ruby's top-level rand are not cryptographically secure — they're Mersenne Twister, which is just as predictable as JavaScript's Math.random(). Always reach for the dedicated secrets / SecureRandom module for security-relevant draws.
Common Mistakes
Generate secure random numbers
Unbiased integers, hex strings, byte arrays — all from the Web Crypto CSPRNG, in your browser.