Blogchevron_rightSecurity
Security

Cryptographically Secure Random Numbers in JavaScript

Most JavaScript code reaches for Math.random() by reflex. For shuffling a deck of cards in a UI demo that's fine. For anything a user can profit from predicting, it's a security bug.

December 1, 2026·6 min read·Generate secure random numbers →

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 caseAPI
Particle effects, jitter, animationMath.random()
Procedural map generation in a gameSeeded PRNG (e.g. seedrandom)
Randomly sampling rows for analyticsMath.random()
A/B test bucket assignmentHash of stable user ID
Session ID, CSRF token, password reset linkcrypto.getRandomValues
Generated password, API key, secretcrypto.getRandomValues
UUIDcrypto.randomUUID()
Symmetric encryption key, IV, noncecrypto.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

closeUsing Date.now() as a "seed" — the attacker knows the current time within milliseconds.
closeReusing a UUID as a random number — UUID v4 has only 122 bits of entropy spread across a fixed format, and v1/v7 leak time info.
closeCalling Math.random() then "mixing" it with a hash — the output is still bounded by Math.random()'s 128 bits of state.
closeGenerating long secrets by concatenating short Math.random() outputs — concatenation does not add entropy from a deterministic source.
closeSkipping rejection sampling because "the bias is small" — small bias compounds over many draws and is detectable in audits.
closeUsing a PRNG seeded from window.performance.now() — high-resolution but still bounded entropy and globally observable.
closeTrusting a third-party "random API" over the network — adds attack surface, latency, and a logging risk for no benefit over the local CSPRNG.
closeCaching the result of crypto.getRandomValues() and reusing it — the whole point is fresh entropy per draw.

Generate secure random numbers

Unbiased integers, hex strings, byte arrays — all from the Web Crypto CSPRNG, in your browser.

Open Random Number Generator →

Related Tools

Cryptographically Secure Random in JS