Where Do Random Numbers Come From in Your Browser?

Published on May 14, 2026 by The Kestrel Tools Team ‱ 8 min read

It’s late, you’re shipping a feature, and somewhere in your code you write Math.random() to generate a token. Or you click a password generator in a browser tab and a 24-character string fills the field instantly. Both feel the same — a tiny bit of magic, a number out of nowhere — but only one of them is actually safe to put in front of a real user.

Browser random number generator security is one of those topics most developers absorb by osmosis: “use crypto.getRandomValues, not Math.random.” That advice is correct, but it’s the punchline without the joke. If you don’t know where the bytes come from, it’s hard to reason about edge cases — old browsers, embedded WebViews, server-side rendering, library defaults that quietly use the wrong API.

This is the same conversation that resurfaces on Hacker News every time the classic Myths about /dev/urandom essay climbs the front page. The myths are about Linux, but the misunderstandings show up one layer up — in the browser tab where you and I generate UUIDs, session IDs, password reset tokens, and the random samples we feed into client-side utilities at Kestrel Tools. Let’s walk through where those numbers actually come from.

Where do random numbers come from in your browser?

The browser gets random numbers from the operating system’s cryptographically secure random source — /dev/urandom on Linux and macOS, BCryptGenRandom on Windows, getentropy on the BSDs — exposed to JavaScript through crypto.getRandomValues() and crypto.randomUUID(). That OS source is seeded by hardware entropy: keystrokes, mouse jitter, disk timing, network packet arrival times, and on modern CPUs the RDRAND / RDSEED instructions. The browser doesn’t roll its own randomness; it borrows from the same pool that protects your SSH keys.

Math.random() is a completely separate function. It is not connected to the OS entropy pool. In every modern browser it’s an xorshift128+ (or similar non-cryptographic) PRNG seeded once per realm with a small amount of entropy and then iterated deterministically. That makes it fast and predictable in a benign sense — great for animation jitter or shuffling a deck in a game, terrible for anything an attacker would care about predicting.

Math.random vs crypto.getRandomValues: a side-by-side

Here is the practical difference, the kind you can put on a code review checklist:

AspectMath.random()crypto.getRandomValues()
Source of bitsNon-cryptographic PRNG, seeded from a small poolOS CSPRNG (urandom / BCryptGenRandom / getentropy)
Output rangeFloat in [0, 1)Fills a typed array (Uint8Array, Uint32Array, etc.)
Predictable from previous outputs?Yes, with enough samplesNo (computationally infeasible)
Safe for tokens, IDs, passwords, keys?NoYes
Available in Web Workers?YesYes
Blocks waiting for entropy?NoNo (modern browsers; older Safari was a pre-2019 edge case)
SpecECMAScript 2015 §20.2.2.27Web Crypto, W3C Recommendation

Attacks on Math.random() are not theoretical. In 2015, security researcher Mike Malone published a working exploit that recovered the internal state of V8’s Math.random() from just a handful of outputs, then predicted every future value. Modern engines have hardened the implementation, but the contract has never changed: Math.random() makes no security promises. None.

If the value will ever be checked by an attacker — a session ID, a password reset link, a CSRF token, a password — it must come from crypto.getRandomValues().

How crypto.getRandomValues actually works

Under the hood, crypto.getRandomValues(buffer) is a thin wrapper around a system call. Here’s the call chain on a typical Linux desktop running Chrome:

JS: crypto.getRandomValues(new Uint8Array(32))
  ↓ V8 calls into Blink
  ↓ Blink calls Boring SSL's RAND_bytes()
  ↓ BoringSSL pulls from /dev/urandom (or RDRAND if available)
  ↓ Linux kernel mixes hardware entropy → ChaCha20 stream
  ↓ 32 bytes return up the stack

A few things are worth noticing:

  • The browser is not the source of randomness. It’s a courier.
  • The kernel’s CSPRNG never “runs out” of entropy in normal operation. After it has been seeded once with enough hardware noise, it generates an effectively unlimited stream of bits using a stream cipher (ChaCha20 on Linux ≄ 4.8, AES-CTR on Windows). This is the central insight from the urandom myths essay — and it applies equally to the bytes the browser hands you.
  • The maximum size of a single call is 65,536 bytes. If you need more, call it in a loop. There is no performance penalty for doing so; modern implementations generate gigabytes per second.

A minimal, correct example:

// Generate a 256-bit random key as a hex string
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');

Thirty-two bytes of pure CSPRNG output, suitable for an HMAC key, an OAuth state parameter, or the seed for a password.

What about crypto.randomUUID()?

Added to the Web Crypto spec in 2021 and now in every evergreen browser, crypto.randomUUID() returns a version 4 UUID string built from 122 bits of crypto.getRandomValues() output:

crypto.randomUUID();
// → '8f14e45f-ceea-467a-a7e3-6c2c9d0a0a17'

It is, byte for byte, the right way to mint a v4 UUID in client-side JavaScript. No more Math.random()-based UUID hacks copied from a 2014 Stack Overflow answer. No npm dependency. No 600-line UUID library.

The one limitation: crypto.randomUUID() only produces v4 UUIDs (random). If you need a v7 UUID — the time-ordered variant from RFC 9562 that’s increasingly popular for database primary keys — you build it yourself on top of crypto.getRandomValues(). Our UUID Generator supports both, and runs entirely in your browser tab, so you can mint hundreds of v4 or v7 UUIDs without ever sending bytes to a server.

When Math.random is fine (and when it absolutely isn’t)

Math.random() is not a security bug in itself — it is a tool with a specific job. Use it when the consequence of an attacker predicting the next value is nothing:

  • Picking a random color for a UI gradient.
  • Choosing which tip to show on an empty state.
  • Jittering a polling interval to spread server load.
  • Shuffling a list of marketing testimonials.
  • Generating a random sample for a non-cryptographic A/B bucket (though prefer crypto.getRandomValues() if the bucket assignment ever feeds analytics that affect product decisions).

Do not use Math.random() for any of these:

  • Session tokens, JWT IDs, or refresh tokens.
  • CSRF tokens.
  • Password reset links or magic-link tokens.
  • Passwords (even temporary ones — “temp123!” is forever once it’s been generated).
  • API keys or any string a user trusts as a credential.
  • Cryptographic salts, nonces, or IVs.
  • Game seeds in any game where money is on the line.
  • UUIDs used as security boundaries (e.g. “only people who know the URL can view this resource”).
  • Random sampling in a study where bias matters.

A good rule from the senior dev playbook: if you find yourself wondering whether Math.random() is good enough, it isn’t. Switch to crypto.getRandomValues(). The performance difference is a fraction of a microsecond. The reasoning difference is your weekend.

How a browser-based password generator uses entropy

When you click “Generate” on a client-side password generator, the entire flow happens in your tab. Here is roughly what runs:

function generatePassword(length, alphabet) {
  const out = new Array(length);
  const bytes = new Uint8Array(length);
  // Pull length bytes of CSPRNG output from the OS via the browser.
  crypto.getRandomValues(bytes);
  // Use rejection sampling to avoid modulo bias.
  for (let i = 0; i < length; i++) {
    let b = bytes[i];
    while (b >= alphabet.length * Math.floor(256 / alphabet.length)) {
      const refill = new Uint8Array(1);
      crypto.getRandomValues(refill);
      b = refill[0];
    }
    out[i] = alphabet[b % alphabet.length];
  }
  return out.join('');
}

Three details that matter for browser random number generator security:

  1. The bytes never leave the tab. No network request, no server log, no cache. The password exists only in your browser’s memory until you copy it.
  2. Rejection sampling avoids modulo bias. If the alphabet has 70 characters, simply doing b % 70 would slightly favor the first 16 characters (since 256 isn’t divisible by 70). The loop discards bytes that would skew the distribution.
  3. Entropy is calculated from the alphabet size and length. A 16-character password drawn from a 94-character printable ASCII alphabet has log2(94^16) ≈ 105 bits of entropy — enough to resist offline attacks for the foreseeable future, regardless of GPU cost curves.

You can verify all of this in your browser’s dev tools: open Network, generate a password, and watch nothing happen. That’s the entire point of doing it client-side.

Edge cases worth knowing

A handful of corners where browser randomness gets weird:

  • Insecure contexts. crypto.getRandomValues() works on HTTP, but crypto.subtle (the rest of Web Crypto, including crypto.randomUUID in some older builds) requires HTTPS or localhost. If you’re prototyping over HTTP, prefer to test on localhost.
  • WebViews and old embedded browsers. Android System WebView versions before 2019 had implementation bugs in early Chromium builds. If you ship to embedded devices, sanity-check the browser version.
  • Server-side rendering. Don’t call crypto.getRandomValues() during SSR and re-use the value on the client — you’ll either leak the same value to every visitor or hydrate-mismatch. Generate on the client, where the user is.
  • Web Workers. crypto.getRandomValues() is available in workers. If you need a lot of randomness on a hot path, generate it in a worker and post it back.
  • Test environments. Math.random() is reproducible if you mock it. crypto.getRandomValues() is harder to mock; use vi.spyOn(crypto, 'getRandomValues') (Vitest) or seam in a small helper in production code that you swap in tests.

A short checklist for your next code review

When a PR contains the word random, scan for:

  • Any use of Math.random() in a file path that includes auth, token, session, password, or crypto. Flag it.
  • Any UUID generation library older than crypto.randomUUID(). Replace it.
  • Any homemade “shuffle” function used on a security-relevant list. Use Fisher–Yates with crypto.getRandomValues().
  • Any “random delay” used to obscure timing. That’s not how timing-safe code works — use crypto.subtle.timingSafeEqual or its equivalent.
  • Any seed value that’s less than 128 bits. If an attacker can guess the seed, the rest is theater.

That checklist plus a working knowledge of where the bytes come from will catch ninety-five percent of randomness bugs before they ever reach production.

Try it yourself

If you’d like to see CSPRNG output without writing any code, our Password Generator and UUID Generator at Kestrel Tools both run entirely in your browser tab, pulling from crypto.getRandomValues() for every character. Open dev tools, watch the empty Network panel, and you’ll see what “client-side” actually means: the bytes start in the OS, end in your clipboard, and never touch a server in between.

That’s where random numbers come from in your browser. Now you can stop trusting the magic and start trusting the path.