Cryptographic Random Number Guide
Not all random numbers are created equal. Math.random() produces pseudorandom values suitable for animations and games, but completely unsuitable for security-sensitive operations like token generation, session IDs, or password reset links. The difference is not academic — attackers have exploited predictable random number generators to hijack accounts, bypass authentication, and extract encrypted data. This guide explains why Math.random() fails for security and how to use the Web Crypto API correctly.
Why Math.random() Is Unsafe
Math.random() uses a deterministic pseudorandom number generator (PRNG), typically an xorshift128+ variant. It produces a sequence that appears random but is entirely determined by its internal state. If an attacker can observe enough outputs, they can reconstruct the state and predict all future — and past — values.
The attacks are practical:
- State recovery: Observing roughly 160 bits of
Math.random()output (about 5 consecutive values) is enough to reconstruct the internal state in V8's implementation. - Cross-tab leakage: All tabs in the same browser process share the same PRNG state. A malicious website in one tab can observe
Math.random()calls and correlate them with token generation in another tab. - Node.js server-side: On the server,
Math.random()state is shared across all requests. One compromised request can predict tokens for all other concurrent sessions.
// UNSAFE — predictable token
function generateToken() {
return Math.random().toString(36).slice(2) // Predictable!
}
// If attacker observes output "k0a1b2c3d", they can
// reconstruct PRNG state and predict the next token.
The V8 team explicitly documents that Math.random() is not cryptographically secure. It was never designed to be — it optimizes for speed, not unpredictability.
The Web Crypto API
The correct replacement is crypto.getRandomValues(), part of the Web Crypto API. It fills a typed array with cryptographically secure random values drawn from the operating system's entropy pool.
// SAFER — cryptographically secure random bytes
function generateSecureToken(length = 32) {
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
}
generateSecureToken() // "a3f1c8e72b4d9f0a6e5c3b8d1f7a2e4c..."
Key properties of crypto.getRandomValues():
| Property | Math.random() | crypto.getRandomValues() |
|---|---|---|
| Cryptographically secure | No | Yes |
| Speed | Very fast | Fast (system call overhead) |
| Output range | [0, 1) float | 0–255 per byte |
| Deterministic | Yes (PRNG) | No (OS entropy) |
| Cross-origin isolation | Shared state | Per-call from OS pool |
| Available in workers | Yes | Yes |
The function is available in all modern browsers and Node.js (as crypto.webcrypto.getRandomValues() or the older require('crypto').randomBytes()).
Common Token Generation Patterns
Session tokens — 256 bits minimum:
function generateSessionToken() {
const bytes = new Uint8Array(32) // 256 bits
crypto.getRandomValues(bytes)
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
}
URL-safe tokens — base64url encoding avoids special characters:
function generateUrlSafeToken(length = 24) {
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
const binary = String.fromCharCode(...bytes)
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
OTP (one-time password) codes — numeric only, fixed length:
function generateOTP(length = 6) {
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
return Array.from(bytes, b => b % 10).join('')
}
The modulo bias in b % 10 is negligible for 6-digit OTPs (bias is 6/256 ≈ 2.3% per digit). For high-value tokens, use rejection sampling instead:
function generateUnbiasedOTP(length = 6) {
const digits = []
while (digits.length < length) {
const bytes = new Uint8Array(1)
crypto.getRandomValues(bytes)
if (bytes[0] < 240) { // 240 = 10 * 24, avoids bias
digits.push((bytes[0] % 10).toString())
}
}
return digits.join('')
}
Node.js Server-Side RNG
On the server, use the node:crypto module:
import { randomBytes, randomInt, randomUUID } from 'node:crypto'
// Random bytes for tokens
const token = randomBytes(32).toString('hex')
// Random integer in range [0, max) without modulo bias
const index = randomInt(0, array.length)
// UUID v4 — cryptographically random
const id = randomUUID()
randomBytes reads from /dev/urandom on Linux and BCryptGenRandom on Windows. Both are CSPRNGs backed by OS-collected entropy.
When to Use Each
| Use Case | Correct API |
|---|---|
| Session tokens | crypto.getRandomValues() / randomBytes() |
| Password reset links | crypto.getRandomValues() / randomBytes() |
| CSRF tokens | crypto.getRandomValues() / randomBytes() |
| UUID v4 | randomUUID() or crypto.getRandomValues() |
| Shuffling a deck in a game | Math.random() (fine for non-security) |
| A/B test assignment | Math.random() (fine for non-security) |
| CSS animation offset | Math.random() (fine for non-security) |
The rule is simple: if the random value protects access, identity, or money, use crypto.getRandomValues(). Everything else can use Math.random() without risk.
Generate secure random numbers for development and testing at /tools/random-number, which supports both standard and cryptographic random generation modes.