Cryptographic Random Number Guide

May 28, 20266 min read

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():

PropertyMath.random()crypto.getRandomValues()
Cryptographically secureNoYes
SpeedVery fastFast (system call overhead)
Output range[0, 1) float0–255 per byte
DeterministicYes (PRNG)No (OS entropy)
Cross-origin isolationShared statePer-call from OS pool
Available in workersYesYes

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 CaseCorrect API
Session tokenscrypto.getRandomValues() / randomBytes()
Password reset linkscrypto.getRandomValues() / randomBytes()
CSRF tokenscrypto.getRandomValues() / randomBytes()
UUID v4randomUUID() or crypto.getRandomValues()
Shuffling a deck in a gameMath.random() (fine for non-security)
A/B test assignmentMath.random() (fine for non-security)
CSS animation offsetMath.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.