How Browsers Generate Cryptographically Secure Random Numbers
Math.random() is not random. It is a deterministic function that produces a sequence which looks random, from a starting value called the seed. Give it the same seed and you get the same sequence, every time, forever.
For a dice roll in a game that is fine. For anything an attacker would benefit from guessing, it is a vulnerability with a long history of being exploited.
What a PRNG actually does
A pseudo-random number generator holds a block of internal state and applies a mixing function to it on every call. Modern JavaScript engines use xorshift128+ or a close relative: a handful of shifts and XORs over 128 bits of state, fast enough to call in a tight loop.
The output passes statistical tests for randomness. The values are evenly distributed, uncorrelated, and show no obvious pattern. What they are not is unpredictable, because the function is public and the state is finite.
Given enough consecutive outputs, the internal state can be reconstructed — xorshift128+ has been solved from a small number of observed values. Once the state is known, every future output is known too. There is no work factor to overcome; it is algebra.
This is not a flaw. Math.random() was designed for speed and statistical quality, and it delivers both. It was never designed to resist an adversary, and the specification has never claimed it does.
What makes a CSPRNG different
A cryptographically secure generator adds two properties that the statistical kind does not have:
Unpredictability. Given every output so far, predicting the next bit must be computationally infeasible — no better than a coin flip.
Backtracking resistance. If an attacker compromises the internal state right now, they must not be able to reconstruct the outputs you produced earlier.
Those requirements rule out simple mixing functions. CSPRNGs are built on cryptographic primitives — a block cipher in counter mode, or a hash-based construction — where recovering the state means breaking the underlying primitive.
Where the entropy comes from
The generator still needs a seed, and this is where the operating system does the real work. The kernel maintains an entropy pool fed by physical events that are genuinely unpredictable:
- Timing jitter between interrupts
- Mouse movement and keystroke intervals
- Disk and network I/O timings
- On modern CPUs, a hardware noise source (
RDSEEDon x86) sampling thermal noise in the silicon
The pool is continuously stirred, and the CSPRNG is seeded and periodically reseeded from it. In the browser you never touch any of this directly; you call one function and the platform hands you bytes from that chain.
The API
// One 32-bit unsigned integer.
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const n = buf[0];
// 32 random bytes, e.g. for a token.
const bytes = crypto.getRandomValues(new Uint8Array(32));
// A UUID v4, already formatted.
const id = crypto.randomUUID();
crypto.getRandomValues fills a typed array in place and is synchronous. It is available in every current browser, and in Node under require('crypto') with the same semantics.
The bias trap
Having secure bytes is not enough — you can still throw the security away when you map them onto a range.
The obvious approach is wrong:
// Biased.
const index = bytes[0] % alphabet.length;
If the alphabet has 62 characters and bytes[0] runs 0–255, then 256 is not a multiple of 62. Values 0–7 can be produced by five different byte values while 8–61 can only be produced by four. The first eight characters of your alphabet come up about 25% more often than the rest.
Over a 16-character password that is a measurable reduction in the real search space — not catastrophic, but entirely avoidable. The fix is rejection sampling: discard any byte that falls in the uneven tail.
function randomIndex(max) {
const limit = Math.floor(256 / max) * max; // largest clean multiple
const buf = new Uint8Array(1);
let value;
do {
crypto.getRandomValues(buf);
value = buf[0];
} while (value >= limit);
return value % max;
}
The loop looks wasteful and is not — for a 62-character alphabet it rejects about 3% of draws.
This is the difference between a generator that is secure in principle and one that is secure in practice, and it is why a password generator is worth using rather than writing from scratch each time.
How to tell which you are looking at
Reading someone else's generator, the tells are quick:
Math.random()anywhere in the path — not secure, regardless of how much hashing happens afterwards. Hashing a predictable input gives a predictable output.new Date().getTime()as a seed — worse. The current millisecond is a search space of a few thousand values for an attacker who knows roughly when the token was issued.%applied directly to random bytes — secure source, biased result.crypto.getRandomValuesplus rejection sampling — correct.
Where it matters
Use the cryptographic source for anything whose value depends on being unguessable: passwords, API tokens, session identifiers, password-reset links, CSRF tokens, invitation codes, and any identifier that grants access by being known.
Math.random() remains the right call for shuffling a playlist, jittering a retry delay, or picking a placeholder image. Speed matters there and secrecy does not.
The decision rule is one question: would anything bad happen if someone predicted this value? If yes, the answer is crypto.getRandomValues. If no, use the fast one and do not think about it again.