DEV Community

Max
Max

Posted on • Originally published at orthogonal.info

Why Math.random() Is a Security Bug in Password Generators (and the Web Crypto Fix)

Last week I was reviewing a small auth service and found this one-liner generating password-reset tokens:

const token = Array.from(
  { length: 16 },
  () => CHARS[Math.floor(Math.random() * CHARS.length)]
).join('');
Enter fullscreen mode Exit fullscreen mode

It runs. It produces things like xK9$mLp2@nQ7vR4w. It also happens to be a real security bug.

That exact pattern is the one I deliberately avoided when I built a small browser-only password generator — and the reason is worth a few hundred words, because almost every "roll your own" password snippet on the web gets it wrong in the same way. Here's what's broken about Math.random() for secrets, the fix, and the two gotchas that bite people who try to fix it themselves.

Math.random() is predictable by design

In V8 — the engine behind Chrome and Node — Math.random() has used an algorithm called xorshift128+ since version 4.9.40 (late 2015). It has 128 bits of internal state, a period of 2^128 − 1, and it passes the TestU01 statistical suite. Statistically, the numbers look random.

But "looks random" and "unpredictable" are different properties.

xorshift128+ is a pseudo-random generator: every output is a deterministic function of that 128-bit state, and the state is recoverable. Feed enough consecutive outputs into a system of linear equations and you can solve for the internal state — there are public tools on GitHub that recover it from as few as 64–128 consecutive Math.random() calls. Once an attacker has the state, every future output is known. Every "random" password you generate after that point is predictable.

For a UI animation or a Monte Carlo sim, who cares. For a password, an API key, or a session token, that's the whole ballgame.

crypto.getRandomValues() is the actual fix

Browsers ship a cryptographically secure RNG (CSPRNG) through the Web Crypto API. It pulls from the OS entropy pool (/dev/urandom on Linux, BCryptGenRandom on Windows) and is built so that observing past output tells you nothing about future output. There's no recoverable internal state to solve for.

The core is four lines:

function secureRandom(max) {
  const arr = new Uint32Array(1);
  crypto.getRandomValues(arr);
  return arr[0] % max;
}
Enter fullscreen mode Exit fullscreen mode

Read a fresh 32-bit unsigned integer from the CSPRNG, reduce it into the range you need, done. Swap Math.random() for this and the prediction attack above is gone.

But notice that % max — that's gotcha number one.

Gotcha 1: modulo bias is real (but size matters)

When you take a random integer modulo your alphabet size, the ranges usually don't divide evenly, so some characters come up more often than others. I wanted to see how bad it actually is, so I generated 6.2 million random bytes and bucketed byte % 62 (a typical alphanumeric set):

  • expected per character: 100,000
  • lowest-frequency char: ~96,900 hits
  • highest-frequency char: ~121,400 hits
  • ratio: 1.25 — a 25% skew

It happens because 256 % 62 = 8, so byte values 0–7 each give one extra shot to the first eight characters.

The textbook fix is rejection sampling: throw away any byte in the biased tail and draw again. Rejecting values ≥ 248 dropped the skew to a 1.02 ratio in my test, at the cost of discarding about 3.1% of draws.

But here's the part the "always use rejection sampling" advice skips: the bias depends entirely on how big your random integer is relative to the alphabet. If you don't read a single byte but a full Uint32 (range 0 to ~4.29 billion), then for a 94-character symbol set, Uint32 % 94 makes the favored characters more likely by roughly 1 part in 45 million — a bias of 0.0000022%.

For a password, that's noise far below anything that matters. So you can skip rejection sampling on purpose and keep the code simple, because a 32-bit draw already makes the bias irrelevant. If you're minting cryptographic keys, add the rejection step; for human passwords, a wide draw is enough.

Gotcha 2: the 64KB quota wall

The second surprise showed up while running that bias test. My first attempt asked getRandomValues() to fill one big buffer:

crypto.getRandomValues(new Uint8Array(620000));
// QuotaExceededError: The requested length exceeds 65,536 bytes
Enter fullscreen mode Exit fullscreen mode

getRandomValues() refuses any request over 65,536 bytes (64 KB) in a single call. It's in the spec and every browser enforces it. If you're generating one 16-character password you'll never hit it, but the moment you batch-generate or fill a large buffer, you have to chunk:

function fillSecure(buf) {
  for (let i = 0; i < buf.length; i += 65536) {
    crypto.getRandomValues(buf.subarray(i, i + 65536));
  }
}
Enter fullscreen mode Exit fullscreen mode

Undocumented in most tutorials, and a hard failure rather than a silent one — which is at least honest of it.

Why browser-only matters here

A password generator that does the work server-side is a service that has seen your password in plaintext. The only design that makes sense for a secret is to build it on the user's machine, from their OS entropy, so it never touches a network. Open dev tools, watch the Network tab while you click generate, and you should see exactly zero requests.

If you want to poke at a working version, here's the browser-only password generator I built around these exact decisions — everything runs client-side.

One layer is never enough

A strong, truly-random password fixes the "guessable" problem. It does nothing about phishing, reused credentials, or a leaked database. Generate unique passwords, store them in a real manager, and gate the important accounts with hardware 2FA. Three cheap layers beat one strong one.

The lesson I keep relearning: in security, the code that "works" and the code that's correct are often the same length and completely different.

Math.random() works. crypto.getRandomValues() is correct.


How do you handle modulo bias in your own token/ID generators — always reject, or do you size the draw so it doesn't matter? Curious what others do in practice.

Top comments (0)