Math.random Is Predictable. Where That Actually Matters
V8 runs xorshift128+ with 128 bits of state an attacker can recover from a few outputs. When to reach for crypto instead.
Table of contents
- Key takeaways
- What is actually wrong with it?
- Where does this actually matter?
- How do I generate a secure random string?
- The mistake in almost every random-string snippet
- How much entropy is enough?
- The other half: you cannot seed it
- Frequently asked questions
- Is Math.random different across browsers?
- Is crypto.getRandomValues slow?
- What about Node's crypto.randomBytes?
- Does Math.floor(Math.random() \* n) introduce bias?
- Can I make Math.random secure by hashing its output?
- Related reading
- References
Math.random() is not broken. It is a good pseudo-random number generator doing exactly what it was designed to do, and its output passes statistical tests for randomness comfortably.
It is also completely predictable to anyone who sees enough of its output. That is a different property entirely, and it matters in precisely one place: anywhere the value is meant to be unguessable.
Key takeaways#
- V8 implements
Math.randomwith xorshift128+, which holds 128 bits of internal state. - Observe a handful of consecutive outputs and that state is recoverable, so every future value is known.
- MDN is unambiguous: it "does not provide cryptographically secure random numbers. Do not use them for anything related to security."
crypto.getRandomValues()is the replacement, capped at 65,536 bytes per call and integer TypedArrays only.- The seed cannot be set, so
Math.randomis also useless where you need reproducible randomness.
What is actually wrong with it?#
Nothing, for the job it has. V8 swapped its old MWC1616 generator for xorshift128+ in December 2015. That took the internal state from 64 bits to 128, with a period of 2^128 − 1.
V8's own post is blunt about the limit. xorshift128+ "is still not cryptographically secure", and "for use cases such as hashing, signature generation, and encryption/decryption, ordinary PRNGs are unsuitable."
The mechanism is the problem, not the quality of the output. A PRNG is a deterministic function of its state. Each call transforms that state and returns a value derived from it, so the sequence is fixed the moment the seed is set. The appearance of randomness is a property of the sequence, not of unpredictability.
Each returned double carries 52 bits of mantissa. So a few consecutive outputs hold more than 128 bits of information about the state. Recovering it is linear algebra, and published tools do it from a handful of values. After that, every future Math.random() is known in advance and every past one can be replayed.
Where does this actually matter?#
Only where a value is meant to be unguessable. That is a shorter list than "everywhere", and a longer one than people assume.
| Use | Generator |
|---|---|
| Password or passphrase | crypto.getRandomValues |
| Session id, API key, token | crypto.getRandomValues |
| Password-reset or verification link | crypto.getRandomValues |
| CSRF token, nonce, salt | crypto.getRandomValues |
| A UUID used as a secret | crypto.randomUUID() |
| Shuffling a playlist | Math.random |
| Jitter on a retry backoff | Math.random |
| Picking a placeholder image | Math.random |
| A/B bucket assignment | Math.random |
| Particle positions in an animation | Math.random |
The right-hand column is the whole decision. If guessing the value gets someone something they should not have, it needs the crypto API. If not, Math.random is faster and fine.
One row is worth pausing on. A UUID is an identifier, not a secret. v4 UUIDs from crypto.randomUUID() are fine as unguessable tokens. A UUID built by hand out of Math.random(), which plenty of snippets still do, is not. And a UUIDv7 leaks its creation time by design, so it is the wrong choice for a secret whatever generated it.
How do I generate a secure random string?#
crypto.getRandomValues() fills a typed array from the platform's cryptographic source. It exists in browsers, in Node, and in every edge runtime worth targeting.
function secureToken(bytes = 32) {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return [...buf].map((b) => b.toString(16).padStart(2, '0')).join('');
}
secureToken(); // 64 hex characters, 256 bits of entropyThree constraints the API imposes, worth knowing before you hit them:
- 65,536 bytes maximum per call. More than that throws
QuotaExceededError. Loop if you genuinely need more. - Integer TypedArrays only, such as
Uint8Array. Float arrays are rejected. - It fills in place and returns the same array, so there is nothing to await.
For a UUID specifically, crypto.randomUUID() is already there and already uses the secure source. Writing your own from random bytes is a way to get the version and variant bits wrong.
The mistake in almost every random-string snippet#
The shape below is everywhere, and it is wrong twice:
// Do not do this
const id = Math.random().toString(36).slice(2);The first problem is the generator. The second is subtler and survives even if you swap in a secure source: toString(36) on a double produces a variable-length string, sometimes 10 characters and sometimes 8, because trailing zeros vanish. Any code assuming a fixed length has an intermittent bug, and the shorter outputs carry less entropy than the longer ones.
Modulo bias is the third trap, and it appears the moment you map bytes onto an alphabet:
// Biased: 256 does not divide by 62, so early characters appear more often
chars[byte % chars.length];For a 62-character alphabet the first 8 characters are selected slightly more often than the rest. It is a small bias and a real one. Rejection sampling removes it: discard any byte above the largest multiple of the alphabet size. That is one reason to prefer a reviewed library over hand-rolling.
How much entropy is enough?#
Entropy is measured in bits, and each bit doubles the search space. The number that matters is how many bits the value actually carries, not how long it looks.
- 128 bits is the working floor for anything long-lived: session ids, API keys, reset tokens. 32 hex characters or 22 base64url characters.
- 256 bits for signing keys and anything where a future advance in compute is a concern.
- A 16-character password from a 94-character alphabet carries about 105 bits. From a 26-character alphabet, about 75.
- A memorable passphrase of six words from a 7,776-word list carries about 77 bits, and people actually remember it.
Length alone means little. A 20-character password from a predictable generator has only as much entropy as the generator's state. For Math.random that is at most 128 bits, and zero once someone has recovered it.
The password generator uses crypto.getRandomValues and reports the entropy it produced rather than a colour-coded strength meter, which is the number worth reading. The random string generator is the same source for the cases where you need a token in a fixed alphabet, and the UUID generator covers identifiers.
The other half: you cannot seed it#
The seed is chosen by the engine and, in MDN's words, "cannot be chosen or reset by the user". So Math.random fails the opposite requirement too.
Anything that needs reproducible randomness cannot use it: a procedurally generated level, a deterministic test fixture, a shuffled dataset a colleague must regenerate. A snapshot test that shuffles with Math.random is a flaky test waiting for an unlucky run.
For that you want a small seeded PRNG you control. It is the one case where writing your own generator is reasonable.
It is also a reminder that "random" covers two incompatible requirements: unpredictable to an attacker, and repeatable on demand. Math.random provides neither.
Frequently asked questions#
Is Math.random different across browsers?#
The algorithm is engine-specific. V8 uses xorshift128+ and other engines have made similar choices, but none of them is cryptographically secure, so the security answer is the same everywhere. Do not rely on the sequence being identical across engines either.
Is crypto.getRandomValues slow?#
Slower than Math.random, and irrelevant at the scale you use it. A 32-byte token takes microseconds. It only becomes measurable in a hot loop, which is a sign you are using it where Math.random would do.
What about Node's crypto.randomBytes?#
Same guarantee, different signature. It is Node's older API and returns a Buffer. crypto.getRandomValues is the cross-platform spelling that works unchanged in the browser, Node and edge runtimes, which is usually the better default now.
Does Math.floor(Math.random() * n) introduce bias?#
Practically no, because a double carries far more bits than any sensible n. The bias that matters comes from mapping bytes onto an alphabet with modulo, where 256 rarely divides evenly by the alphabet size.
Can I make Math.random secure by hashing its output?#
No. Hashing a predictable input gives a predictable output. If the state is recovered, every hash of every future value is recoverable too. The entropy has to come from the source.
Related reading#
- Why SHA-256 Is Wrong for Passwords — the adjacent mistake, with the same shape
- Decoding a JWT Is Not Verifying It — where these tokens end up
- UUIDv4 vs UUIDv7 as a Primary Key — including why v7 leaks creation time
- Generate with the Password Generator, Random String Generator or UUID Generator — all
crypto.getRandomValues, all in your browser.
References#
Tags
- Security
- JavaScript
- Cryptography
- Random
