Randomness and RNG

Why Math.random() must never draw a lottery

Math.random() looks random and passes statistical tests, yet published work recovers its entire internal state from a handful of outputs. We show concretely what 'predictable' means and why no draw should ever depend on it.

Every mainstream JavaScript engine ships a function called Math.random(). It is fast, it is convenient, and its output looks perfectly random to the eye. It is also completely unsuitable for drawing lottery numbers, running a raffle, or backing any claim of fairness — and not in a hand-wavy way. The unsuitability is concrete, published, and reproducible on a laptop.

What Math.random() actually is

The JavaScript language specification deliberately says almost nothing about Math.random(). The ECMAScript standard requires only a value in [0, 1) chosen "using an implementation-defined algorithm or strategy" — and it explicitly does not require cryptographic quality.

In V8 — the engine inside Chrome, Edge and Node.js — Math.random() is implemented with an algorithm called xorshift128+. The V8 team documented the switch in their post "There's Math.random(), and then there's Math.random()": xorshift128+ keeps 128 bits of internal state, has a period of 2^128 − 1, and shipped in Chrome 49, with Firefox and Safari adopting the same algorithm. The entire update step is four shifts and four XORs:

s1 ^= s1 << 23
s1 ^= s1 >> 17
s1 ^= s0
s1 ^= s0 >> 26
// output = s0 + s1 (top bits become the next double)

That is the whole machine. Two 64-bit numbers, shuffled by fixed bit operations. The state is seeded once when the engine starts, and from that moment every future output is determined — the sequence only looks random because the bit-mixing is good at hiding the pattern from statistical tests.

What "predictable" means, concretely

A period of 2^128 − 1 sounds unassailable: that is about 3.4 × 10^38 steps. But an attacker never needs to search the period. They need to solve a small system of bit equations, and modern constraint solvers eat those for breakfast.

In 2016, Douglas Goddard of Independent Security Evaluators published "Hacking the JavaScript Lottery", which does exactly what the title says: it feeds a few consecutive Math.random() outputs into the Z3 theorem prover, expresses the xorshift128+ update as symbolic 64-bit equations, and solves for the two hidden state words. Once the 128-bit state is recovered, the attacker's copy of the generator is a perfect clone: every future output is known in advance. A follow-up post, "XorShift128+ Backward", shows the update step can also be inverted, so past outputs can be reconstructed too — the generator leaks its history as well as its future.

Walk through what that means for a hypothetical "draw" powered by Math.random():

  1. The site publishes results, or exposes any endpoint whose behaviour depends on the generator (shuffled lists, game outcomes, IDs, delays).
  2. An observer collects a handful of consecutive outputs.
  3. A solver recovers the 128-bit state — this is equation-solving, not brute force, so the 2^128 state space is irrelevant.
  4. The observer now knows every "random" number the draw will produce until the engine restarts.

No exploit, no intrusion, no insider. Just arithmetic applied to public outputs.

The three properties a lottery RNG needs — and Math.random() fails all of them

Unpredictability. Given any amount of past output, the next output must be unguessable beyond chance. xorshift128+ fails by design: it is a deterministic recurrence with no secrecy protecting its state, and the state-recovery work above is the proof.

Non-seedability / state protection. A fair draw must not be reproducible by anyone who knows or influences the starting conditions. Math.random() is seeded automatically by the engine, and whoever controls the environment controls the seed. Node.js processes started with the same engine flags and seed material replay identical sequences.

Auditable guarantees. The ECMAScript specification makes no randomness promise at all, and MDN's documentation carries an explicit warning that Math.random() does not provide cryptographically secure numbers and must not be used for anything security-related. You cannot certify fairness on top of a function whose own documentation disclaims it.

"But it passes the statistical tests"

It does — and that is the most instructive part. The V8 post notes xorshift128+ passes TestU01's batteries, the toughest statistical suites in common use (we cover them in how to test a random number generator). Statistical tests measure whether output looks uniform. They say nothing about whether an adversary who has seen outputs can compute the state. Passing BigCrush while being cloneable from a few samples is exactly the gap between a PRNG and a CSPRNG — the subject of PRNG vs CSPRNG vs TRNG.

What to use instead

Browsers ship a second generator specifically built for adversarial settings: crypto.getRandomValues(), which draws from the operating system's cryptographic pool (what the Web Crypto API actually gives you). It is the generator behind our own number generator, combined with rejection sampling so the mapping onto lottery ranges is exactly uniform — the full design is documented in how we built an unbiased generator.

The rule of thumb is short: Math.random() is for particle effects and shuffling a playlist. The moment money, prizes or fairness claims depend on the number, it is the wrong tool — provably, not rhetorically.

Try it yourself

Keep reading

Sources

Last verified: 2026-08-29