Why Math.random() is not secure

The language specification asks for uniformity, not unpredictability. V8 delivers exactly that, and five generators on the first page of Google build passwords on top of it.

Updated 2026-09-05

The specification never promised what people assume

ECMAScript defines Math.random as returning a number greater than or equal to zero and strictly less than one, chosen randomly or pseudo-randomly with approximately uniform distribution, using an implementation-defined algorithm. That is the whole contract. Uniformity is required. Unpredictability is not mentioned, not implied, and not testable by anything in the specification.

This matters because an engine that returns a perfectly predictable sequence violates nothing. It is not a bug to be reported. Every engine is free to choose the fastest algorithm that looks uniform, and every engine has. Math.random is fast because it is allowed to be.

Compare the language the Web Crypto specification uses for crypto.getRandomValues. It requires a cryptographically strong sequence, and it requires implementations to source that from an underlying operating system facility. Two functions in the same runtime, and only one of them was ever asked for the property you need.

If a value would be a problem in an attacker's hands, Math.random is the wrong function regardless of which engine you are on. The argument is about what was specified, not about which implementation happens to be weakest this year.

What V8 actually runs

V8 implements Math.random with xorshift128+, a non-cryptographic generator with 128 bits of state, seeded once per context. Every value that context ever produces is a deterministic function of those 128 bits. Recovering the state from a short run of outputs is published work, and once the state is known the attacker can reproduce every value the context has already emitted and every value it will emit next. There is no forward secrecy and no backtracking resistance, because a generator of this class was never designed to have any.

There is an implementation detail here that surprises people even when they already accept the security argument. For most of its history V8 did not compute one value per call. It refilled a cache of 64 doubles in a batch and then served calls out of that cache using a decrementing index, so a fresh batch was consumed in the reverse of the order it was generated. Anyone who tried to reason about the sequence by calling Math.random in a loop and inspecting the output was reading it backwards. Write this down as behaviour of a particular build rather than a permanent property: the cache was always an optimisation, and V8 15.0 stopped using it on 64-bit builds, where a call is now a single xorshift128+ step. It survives on 32-bit builds because the generated code there cannot work with 64-bit values.

A second detail is worth knowing because it shows how little attention this code path gets. From December 2015 until a release at the end of 2025, V8 took the post-swap s0 word rather than the sum of the two state words, while the comment directly above that line said xorshift128+. What actually shipped was plain xorshift128, in both 32-bit and 64-bit builds, for roughly a decade. V8 14.4 still had it and V8 14.5 returns the sum. Node 24 ships V8 13.6 and so still carries the old behaviour. Nobody noticed, because nothing about Math.random is load-bearing enough for anyone to check.

JavaScriptCore has its own version of the same theme. It seeds both 64-bit state words from a single 32-bit unsigned value, which means Safari has at most 2^32 distinct streams of Math.random output. Four billion possibilities is not a search space, it is an afternoon.

Five generators on page one still use it

We audited fifteen generators that rank on the first page of Google for the terms this site targets, reading their shipped JavaScript rather than their marketing copy. Five of them use Math.random in generator logic. Two use crypto.getRandomValues with correct rejection sampling. Seven generate on a server, where the client cannot verify the algorithm either way. One uses getRandomValues with a biased mapping.

The five below are named because the evidence is a line of public JavaScript that anyone can fetch and read. In every case the mapping from the random float to a character is the correct multiply-and-truncate idiom, so the distribution is fine. The problem is not the shape of the distribution. It is that the value is a function of a recoverable 128-bit state.

Math.random in generator logic, quoted from the shipped first-party JavaScript
GeneratorThe lineWhat it covers
onlinestringtools.comfunction p(r){return r[Math.floor(Math.random()*r.length)]}The entire PRNG for the string generator. Zero getRandomValues calls anywhere in about 284 KB of first-party JavaScript.
browserling.comvar a=h.charAt(Math.random()*h.length)Eight call sites in the string tool, nine more in the password generator, and two in the UUID generator, whose v4 output is built by x.replace(/x/g, ...) over parseInt(Math.random()*16,10).
pinetools.comfunction(x){ return Math.floor(Math.random() * x) }Seven identical call sites in an inline script, plus six more on the password page. The generator is obfuscated behind guards that test window.location.hostname, so it only runs on their domain.
codebeautify.org_0x824157['charAt'](Math['random']()*_0x824157['length'])Ten hits, all of them generator logic: nine in the per-charset branches of random_string() and one in its random_int() helper.
textfixer.comnewPwd = charArray[Math.floor(Math.random()*charArray.length)];The single Math.random hit in the file, and the whole of the character selection.

browserling generates UUID version 4 this way. RFC 9562 requires a cryptographically secure source for the random bits of a v4 UUID, so those values are not conformant UUIDs in the sense the specification means, whatever they look like.

What this argument is not

Finding Math.random in a page is not the same as finding it in the generator. Bootstrap mints DOM element ids with a loop over Math.floor(Math.random()*1e6). Font Awesome mints SVG icon ids from a 62-character pool, which looks exactly like a string generator if you grep without reading. jQuery uses it for expando suffixes and Sizzle counters. Analytics libraries use it for client ids and sampling gates. Ad tags use it for cache busters and retry jitter.

uuidgenerator.net is the case worth naming explicitly, because it is the trap. It generates server-side, and its only Math.random hit anywhere is Bootstrap 5.3.8 getUID. Claiming that site uses Math.random for its UUIDs would be false and trivially disproved by anyone who looked. The honest criticism of a server-side generator is different: you cannot verify what it does, because the code that draws the bytes never reaches you.

The other half of the fairness point runs the same way. randomkeygen and commentpicker both implement rejection sampling over getRandomValues correctly. Neither of them says so anywhere on the page, which is a missed opportunity rather than a defect. Correct randomness is table stakes in this niche, not a differentiator, and pretending otherwise would be a claim we could not defend.

The replacement is one function

crypto.getRandomValues fills an integer typed array from the operating system CSPRNG. It exists in every browser you will meet, on the Node global object by default since Node 19 (17.6.0 behind --experimental-global-webcrypto), in Deno and in Bun. It has one restriction that catches people: the per-call quota is 65,536 bytes, so a Uint32Array is capped at 16,384 elements and larger requests have to loop. It also refuses floating-point arrays outright.

The correct policy for any codebase that mints secrets is a repository-wide ban on Math.random, enforced by a lint rule and a grep in CI rather than by reviewer attention. That is what we do here, and it is checked against the built bundle rather than the source, because the thing that matters is what ships.

One caveat that should be stated plainly rather than buried. Replacing Math.random with getRandomValues fixes predictability. It does not by itself fix uniformity, because the obvious mapping from a random integer to a character index introduces modulo bias. That is a separate problem with a separate fix.

Doing this in your own code

The swap, in one line
// Predictable. Every value comes from a 128-bit xorshift state that is
// seeded once per context. Recover the state, recover the rest.
const bad = alphabet[Math.floor(Math.random() * alphabet.length)];

// Unpredictable. Backed by the operating system CSPRNG.
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const ok = alphabet[buf[0] % alphabet.length];   // see the modulo-bias article

The second line is still not exactly uniform. It is unpredictable, which is the property Math.random() can never give you.

Seeing the batch, in your own console
// Up to V8 14.9, V8 refilled a 64-entry cache in ascending order and
// read it with a decrementing index, so a fresh batch came back in the
// reverse of the order it was generated. V8 15.0 dropped the cache on
// 64-bit builds: there, one call is now one xorshift128+ step.
// An engine implementation detail either way, never a guarantee.
const a = Array.from({ length: 128 }, () => Math.random());

// Nothing here is a security property. It is a demonstration that
// Math.random() is a deterministic function of a small hidden state,
// not a stream of independent secrets.
Node and shell
// Node exposes the same Web Crypto surface on the global crypto object.
// It landed in 17.6.0 behind --experimental-global-webcrypto and has been
// on by default since 19.
crypto.getRandomValues(new Uint8Array(32));
crypto.randomUUID();                    // v4 only

// node:crypto, for byte counts above the 65,536-byte Web Crypto quota
const { randomBytes } = require("node:crypto");
randomBytes(1_000_000);

// A grep that belongs in CI for any project that mints secrets:
//   grep -rn "Math\.random" src/ && exit 1

A repo-wide ban enforced in CI is more reliable than a code review that has to catch it every time.

Questions

Is Math.random() safe if I only need a temporary value?

For a DOM id, a cache buster, a jitter delay or a UI animation, it is fine and it is faster. The test is not how long the value lives, it is whether anyone gains anything by predicting it. A password reset link that expires in fifteen minutes is still a secret for fifteen minutes.

Can I make Math.random() secure by hashing its output or mixing several calls?

No. Hashing a value drawn from a 128-bit state produces a value that still has at most 128 bits of unpredictability, and in practice much less, because the state is shared across every call in the context. Mixing several calls together mixes several outputs of the same generator, which does not add entropy that was never there.

Is the reverse-order batching a security problem on its own?

No. It is an engine implementation detail and it is not the reason Math.random is unsuitable. It is worth knowing because on a build that still batches, the sequence you observe is not the sequence the generator produced, which trips up people trying to reason about the output empirically. V8 15.0 dropped the cache on 64-bit builds, so the effect is disappearing anyway. The security argument rests on the 128-bit state, not on the cache.

Do the five named generators produce non-uniform output?

Not measurably. All five use the multiply-and-truncate mapping rather than a modulo, so the residual non-uniformity is far below anything observable. Their output passes a chi-square test. Predictability is the defect, and a statistical test cannot detect it.

How would an attacker actually exploit a Math.random token?

The realistic route is to obtain several values that the same page context produced, which is often easy because such tools hand out values in batches, then recover the internal state and reproduce every other value that context generated. The published attacks on xorshift128+ need only a handful of outputs. Whether that is worth an attacker's time depends entirely on what the token protects.

Try it

Related reading