🍱 Lunchbox Hands

javascript

Is Math.random() Random Enough? (Usually No)

Math.random vs crypto.getRandomValues: why V8 output is predictable from a few samples, how modulo bias loads a dice roll, and the JavaScript shuffle bug.

You wrote Math.floor(Math.random() * entries.length) to pick a giveaway winner, it looked fine in testing, and nobody has complained. Math.random() is a perfectly good source of randomness right up to the moment someone has a reason to predict it — and the line between those two worlds is not “is this important,” it’s “can anyone profit from guessing the next value.” Here’s what the function actually guarantees, what it doesn’t, and the two arithmetic mistakes that bias results even when the underlying source is flawless.

Every draw on our random number generator comes from crypto.getRandomValues and is mapped into your range with rejection sampling, for reasons this post spends the next thousand words on.

What Math.random() actually promises

Almost nothing. The ECMAScript specification asks for a floating-point value in [0, 1) “with approximately uniform distribution over that range,” produced by an implementation-defined algorithm. MDN is blunter: “Math.random() does not provide cryptographically secure random numbers. Do not use them for anything related to security. Use the Web Crypto API instead.”

Two consequences fall out of “implementation-defined”. You cannot seed it — as MDN puts it, “the implementation selects the initial seed to the random number generation algorithm; it cannot be chosen or reset by the user,” so for reproducible randomness in tests or procedural generation you need a seedable PRNG library instead. And you cannot rely on the distribution across engines: “approximately uniform” is the whole contract. Fine in practice; not a guarantee you can build a fairness claim on.

PRNG vs CSPRNG, in one paragraph

A PRNG keeps a small amount of internal state and applies a fixed function to it for each output. Same state, same sequence — deterministic by construction. The design goal is that outputs look uniform and uncorrelated, and under statistical tests they do.

A CSPRNG adds one requirement: seeing past outputs must not let you compute the state or predict future ones. That is what costs the extra cycles, and it’s the only difference that matters when an adversary exists. Math.random() is a PRNG; crypto.getRandomValues() is seeded by the operating system’s CSPRNG.

The predictable part

V8 — Chrome, Edge, Node, Deno, Cloudflare Workers — has used xorshift128+ for Math.random() since V8 4.9.41.0, shipped in Chrome 49 back in early 2016. The V8 team’s own write-up describes it as “128 bits of internal state” with “a period length of 2^128 − 1”, and ends with the caveat that matters here: “even though xorshift128+ is a huge improvement over MWC1616, it is still not cryptographically secure. For use cases such as hashing, signature generation, and encryption/decryption, ordinary PRNGs are unsuitable.” The encryption item on that list is the sharpest: a stream cipher is nothing but a generator’s output XORed into your plaintext, so a recoverable state hands the plaintext straight back — the same break that undoes a repeating-key XOR.

That “not cryptographically secure” is not theoretical. Recovering the 128-bit state from observed outputs has been publicly demonstrated more than once — the usual approach feeds a handful of consecutive Math.random() values into the Z3 SMT solver and solves for the state. Once the state is known, every subsequent value is exact, not probabilistic.

The shape of the attack changes what counts as “exposed.” An attacker does not need your server; they need outputs. If the same Math.random() sequence that picks your raffle winner also generates the ticket IDs you hand back to entrants, the promo codes you email, or the ?share= token in a URL, you have been publishing samples of your own PRNG stream all along.

The APIs you should be using instead

APIWhereGives youNotes
crypto.getRandomValues(typedArray)Browser + NodeFills the array with CSPRNG bytesThrows QuotaExceededError above 65,536 bytes per call
crypto.randomUUID()Browser + NodeA v4 UUID stringBrowser version is secure-context only (HTTPS or localhost)
crypto.randomInt(min, max)Node onlyUnbiased integer, max exclusivemax - min must be ≤ 281,474,976,710,655 (2⁴⁸ − 1)
crypto.randomBytes(n)Node onlyBuffer of CSPRNG bytesAsync form preferred in request paths
Math.random()EverywhereA float in [0, 1), fastPredictable; never for anything adversarial

crypto.randomInt is the one people miss: it exists precisely so you don’t have to write the next section yourself. In the browser there is no equivalent — you get bytes, and the mapping is on you.

Modulo bias: byte % 6 is not a fair die

Here is the failure that survives switching to a secure source. You have a perfect random byte, 0–255, and you want a die roll:

const b = crypto.getRandomValues(new Uint8Array(1))[0];
const die = (b % 6) + 1; // looks fine. isn't.

256 is not divisible by 6. Divide it out: 256 = 42 × 6 + 4. Every residue gets 42 byte values, and then there are 4 bytes left over — 252, 253, 254, 255 — which map to residues 0, 1, 2, and 3. So:

FaceByte values mapping to itProbabilityFair would be
1, 2, 3, 443 each43/256 = 16.797%16.667%
5, 642 each42/256 = 16.406%16.667%

Faces 1 through 4 come up 2.38% more often than faces 5 and 6 (43/42 = 1.0238). Over 10,000 rolls that’s about 1,680 ones against 1,641 sixes — invisible at a tabletop, glaring in anything that runs a million times and pays out.

The fix is rejection sampling: throw away the leftover values so the remaining ones divide evenly.

// Discard 252-255, then the remaining 252 values split exactly 42 per face.
function rollD6() {
  const buf = new Uint8Array(1);
  for (;;) {
    crypto.getRandomValues(buf);
    if (buf[0] < 252) return (buf[0] % 6) + 1;
  }
}

You throw away 4 draws in 256 — 1.56% — and get exact uniformity. The loop is unbounded in theory and terminates immediately in practice, and with a 32-bit draw instead of a byte the waste collapses to nothing. That is what our generator does:

const limit = Math.floor(2 ** 32 / range) * range;
for (;;) {
  const u = /* 32 random bits from crypto.getRandomValues */;
  if (u < limit) return min + (u % range);
}

For a d6, limit is 4,294,967,292 — exactly 4 rejected values out of 2³², so roughly one draw in 1.07 billion gets discarded, and every face is exactly equally likely.

The bias scales with range ÷ draw size, which is why it’s ignorable in some places and not others. Drawing from an 88-character password alphabet with uint32 % 88 favors 48 of those characters by about one part in 49 million — real, and unobservable in this universe, which is why the password generator and API key generator use exactly that mapping over CSPRNG bytes without flinching. The same shortcut over a single byte gives you a loaded die.

If you want the other half of that story — how many bits of entropy a given alphabet and length actually buy — that’s password entropy explained.

The other arithmetic trap: sort(() => Math.random() - 0.5)

This one-liner shows up in every “shuffle an array in JavaScript” answer, and it is not a shuffle:

const shuffled = items.sort(() => Math.random() - 0.5); // no

A sort algorithm assumes its comparator is consistent — if a < b and b < c, then a < c. A random comparator violates that on every call, and the ECMAScript spec’s response is that the resulting order is implementation-defined. What you get is a fingerprint of the engine’s sort algorithm, not a permutation.

Here is the measured distribution, 600,000 shuffles of [1, 2, 3] in Node 22 (V8):

ResultFrequencyFair would be
12337.51%16.67%
32131.26%16.67%
21312.57%16.67%
2316.25%16.67%
3126.23%16.67%
1326.18%16.67%

The array is more likely to come back untouched than in any other single order, by a factor of six over the rarest outcome. With four elements it gets worse: 1234 came back 18.7% of the time against a fair 4.17%, while the rarest permutation managed 1.5%. The exact numbers are V8’s — it uses binary insertion sort for short runs inside TimSort, and other engines skew differently — but “differently” is the only engine-specific part. No engine gives you a uniform shuffle from an inconsistent comparator.

Fisher–Yates is the fix, and it’s five lines:

function shuffle(a) {
  for (let i = a.length - 1; i > 0; i--) {
    const j = randomInt(0, i); // unbiased, inclusive both ends
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

Same 600,000-trial harness, same three elements: every permutation landed within about a tenth of a point of 16.67%, which is what sampling noise looks like. Note that randomInt(0, i) has to be unbiased too — a Fisher–Yates loop fed by Math.floor(Math.random() * (i + 1)) fixes the algorithmic bias and keeps the predictability: right for a music player, wrong for a card game with money on the table.

Our generator’s “no repeats” lottery mode is this algorithm — a partial Fisher–Yates over small ranges, Floyd’s sampling algorithm once the range gets big enough that allocating it would be silly. Six numbers from 1–49 are six genuinely distinct values, drawn the way a ball machine draws them.

A name draw has one more rule on top of fairness: nobody may draw themselves. Our Secret Santa generator draws with crypto.getRandomValues too, and Secret Santa without a hat covers why a hat fails that rule about two times in three.

When Math.random() is genuinely the right call

It isn’t forbidden. It’s the right default whenever nobody benefits from predicting the outcome: particle positions and animation jitter (where you call it thousands of times per frame), jittered retry backoff, log and trace sampling, picking one of three loading messages, dice in a single-player game, a “pick a random team member for standup” button.

That last category is where a spinning wheel usually sits, and most wheel sites do exactly that — pick the slice with Math.random(), because a standup rotation rewards nobody for predicting it. Ours does not: the wheel spinner picks the winning index from crypto.getRandomValues with rejection sampling before the animation starts, so the confetti is theatre and the draw is not. The dice roller and coin flip work the same way: every roll and flip comes from crypto.getRandomValues with rejection sampling, so no face or side gets the modulo-bias edge described above.

The cost argument for Math.random() is smaller than people assume: crypto.getRandomValues is fast, and the only thing to avoid is calling it once per value in a tight loop, where per-call overhead dominates. Fill one big typed array and consume it.

Decision table

What you’re doingUse
Visual noise, jitter, animation, retry backoff, log samplingMath.random()
Dice in a single-player gameMath.random() is fine; crypto costs you nothing either
Giveaway, raffle, prize drawcrypto.getRandomValues + rejection sampling
Shuffling a deck in a multiplayer gameFisher–Yates over a CSPRNG
Passwords, API keys, session IDs, reset links, CSRF tokenscrypto.getRandomValues, ≥ 128 bits
Database primary keyscrypto.randomUUID() — or UUIDv7 / ULID if insert order matters
Anything you’d be embarrassed to see predictedCSPRNG, always

That second-to-last row has its own trade-offs — random IDs are unguessable and terrible for index locality — which is the subject of UUID vs ULID. For a quick v4, the UUID generator calls crypto.randomUUID() directly.

What to do

  1. Grep for Math.random. For each hit, ask one question: does anyone gain by predicting this? If yes, swap it for crypto.getRandomValues or Node’s crypto.randomInt.
  2. Grep for sort(() =>. Replace every random comparator with Fisher–Yates — a correctness fix, not a security one, so your threat model doesn’t get a vote.
  3. Check every % n applied to random bytes. If n doesn’t divide your draw size evenly, reject the overflow or widen the draw until the bias is beneath noticing.
  4. Stop publishing your PRNG stream. Ticket numbers, share tokens, and order references derived from Math.random() are samples an attacker can collect.
  5. When the draw needs to be defensible, don’t roll it yourself. The random number generator draws from crypto.getRandomValues, maps into your range with the rejection-sampling loop above, and does lottery-style no-repeat draws with Fisher–Yates — numbers in any range, d4 through d20 plus custom dice up to 1,000 sides, and coin flips with streak stats. It runs entirely in your browser, and the session history dies with the tab.

The summary is short: Math.random() is a fine number generator and a terrible secret generator, and a secure source still gives you a loaded die if the arithmetic on top of it is wrong.