If you've ever written Math.random().toString(36).slice(2) to generate a "random" token, this post is for you. It works, it looks random, and it's the wrong tool for anything security-related. Here is why, and what to use instead.
What Math.random() actually is
Math.random() returns a pseudo-random number: the output of a deterministic algorithm that starts from an internal state (the seed) and produces the same sequence every time it starts from the same state.
The ECMAScript spec only asks for an "approximately uniform" distribution. It makes no security guarantees at all, and leaves the algorithm up to each engine. V8 (Chrome, Node.js) uses xorshift128+, which is fast and statistically decent, but it is not designed to resist an attacker.
The practical consequence: with xorshift128+, the internal state can be reconstructed from a small number of consecutive outputs, and once an attacker has the state, every future output is predictable. This has been demonstrated publicly with off-the-shelf constraint solvers. If you use Math.random() to produce a reset token, a session ID, or a password, an attacker who can observe a few outputs from the same generator may be able to predict the rest.
For a particle animation or shuffling a playlist, none of this matters. For anything where someone benefits from guessing the next value, it does.
What to use instead: crypto.getRandomValues()
The Web Crypto API exposes a cryptographically secure pseudo-random number generator (CSPRNG), seeded from the operating system's entropy source:
const bytes = new Uint32Array(4);
crypto.getRandomValues(bytes);
console.log(bytes); // e.g. Uint32Array(4) [ 2871635120, 912443081, ... ]
Key facts:
It works in all modern browsers and in Node.js (globalThis.crypto, or require('node:crypto').webcrypto in older versions).
It fills a typed array in place. Max 65,536 bytes per call, otherwise it throws a QuotaExceededError.
Its outputs are designed so that observing some of them gives you no practical way to predict the others.
For UUIDs, crypto.randomUUID() gives you a v4 UUID directly (secure contexts only, i.e. HTTPS or localhost).
On the server in Node.js, crypto.randomInt(min, max) does the right thing out of the box (note that max is exclusive).
The trap: modulo bias
Here is where careful developers still go wrong. You have a secure random number and you need an integer in a range, so you write:
// DON'T
const n = crypto.getRandomValues(new Uint8Array(1))[0] % 200;
A random byte has 256 possible values. 256 % 200 = 56, so the values 0 to 55 each come up twice as often as 56 to 199. The source was secure, but the output is skewed. For a password character set it quietly reduces your entropy; for a lottery draw it's plainly unfair.
The fix is rejection sampling: throw away the values that fall in the uneven tail.
function secureRandomInt(min, max) {
// inclusive range [min, max]
const range = max - min + 1;
if (!Number.isSafeInteger(range) || range < 1 || range > 2 ** 32) {
throw new RangeError('Invalid range');
}
// Largest multiple of range that fits in 32 bits
const limit = Math.floor(2 ** 32 / range) * range;
const buf = new Uint32Array(1);
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit); // reject the biased tail
return min + (x % range);
}
The loop almost never repeats more than once or twice, so the cost is negligible.
I checked this against 600,000 simulated six-sided die rolls: each face landed within about 0.1 percentage points of the ideal 16.67%, with no face favored.
A complete example: a password generator
Now we can build something real. This version guarantees at least one character from each set, fills the rest from the combined pool, then shuffles with a Fisher-Yates shuffle that also uses secure randomness (a shuffle with Math.random() would undo the work):
function generatePassword(length = 16) {
const sets = {
lower: 'abcdefghijklmnopqrstuvwxyz',
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digits: '0123456789',
symbols: '!@#$%^&*()-_=+[]{}',
};
const all = Object.values(sets).join('');
// One guaranteed character from each set
const chars = Object.values(sets).map(
s => s[secureRandomInt(0, s.length - 1)]
);
// Fill the rest from the full pool
while (chars.length < length) {
chars.push(all[secureRandomInt(0, all.length - 1)]);
}
// Secure Fisher-Yates shuffle
for (let i = chars.length - 1; i > 0; i--) {
const j = secureRandomInt(0, i);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
console.log(generatePassword(16)); // e.g. "T07@iRz!Sh+4yiAT"
Over 20,000 generated passwords, every one met the length and character-set rules.
Entropy check: the combined pool has 80 characters (26 + 26 + 10 + 18), so each character adds log2(80) ≈ 6.32 bits and a 16-character password carries roughly 101 bits. The guaranteed characters shave off a little, which is a fair trade for meeting typical site rules.
When it actually matters
Use crypto.getRandomValues() (not Math.random()) for:
Passwords, API keys, reset tokens, session IDs
Raffles, giveaways, anything with money or fairness at stake
Cryptographic keys, nonces, salts (never Math.random() here)
Math.random() is fine for:
Visual effects, particles, jitter
Shuffling a non-critical list (the CSPRNG is optional but harmless)
Rule of thumb: if someone gains something by predicting or biasing the result, use the CSPRNG. It's a one-line change and the performance difference is irrelevant for human-scale use.
Wrapping up
Math.random() is predictable by design and gives no security guarantees.
crypto.getRandomValues() is the right primitive, in browsers and in Node.
A secure source can still give biased output: avoid % n on raw random values and use rejection sampling.
Shuffle with secure randomness too, or the shuffle becomes your weak link.
If you'd rather see this in action than write it yourself, I built a small set of generators on exactly this approach (numbers, passwords, UUIDs, names, dice and more) that run entirely in your browser, so you can open the Network tab and confirm nothing is sent anywhere: Generador Studio. Feedback is welcome, especially on which generator you'd like to see next.
Top comments (1)
You need to complete account verification.Link in the profile.