Why Math.random() Is Unsafe for Passwords — and How to Use crypto.getRandomValues Instead
If you have ever written a password generator in JavaScript, you may have reached for Math.random(). It works, the output looks random, and nobody will notice. Right?
Wrong. Using Math.random() for anything security-sensitive is a significant vulnerability. This article explains why, shows you the safe alternative, and covers the subtle pitfalls that even careful developers trip over.
The Problem With Math.random()
Math.random() is a Pseudo-Random Number Generator (PRNG). It produces numbers that look random, but they are entirely deterministic — the output is derived from an internal seed using a mathematical formula.
In V8 (Node.js / Chrome), the PRNG is based on xorshift128+. The algorithm is fast and has good statistical properties for simulations or game logic. But for cryptographic purposes, it has a critical flaw: the state is predictable.
Research has demonstrated that by observing a handful of consecutive Math.random() outputs, an attacker can reconstruct the internal 128-bit state and predict all future (and past) outputs. See this 2017 analysis by Filedescriptor for a practical demonstration.
// ❌ NEVER do this for security-sensitive values
function unsafePassword(length) {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let password = "";
for (let i = 0; i < length; i++) {
password += chars[Math.floor(Math.random() * chars.length)];
}
return password;
}
If this function runs in a browser, an attacker who can execute any JavaScript on the same page (via XSS, a malicious extension, or a compromised dependency) can predict your output.
The Solution: crypto.getRandomValues()
The Web Crypto API provides crypto.getRandomValues(), which fills a typed array with cryptographically secure random bytes sourced from the operating systems entropy pool (/dev/urandom on Linux/macOS, BCryptGenRandom on Windows). This is the same entropy source used for TLS key generation.
// ✅ Cryptographically secure random bytes
const array = new Uint8Array(16);
crypto.getRandomValues(array);
console.log(array); // 16 unpredictable bytes
This API is available in all modern browsers and in Node.js (≥ 15) without any import.
A Correct Password Generator
Here is a full implementation that is secure and free from the modulo bias pitfall (covered in the next section):
/**
* Generates a cryptographically secure random password.
* @param {number} length - Desired password length
* @param {string} charset - Characters to sample from
* @returns {string}
*/
function securePassword(length = 16, charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*") {
const charCount = charset.length; // e.g. 72
const randomBytes = new Uint8Array(length * 2); // over-allocate to handle rejection
const result = [];
while (result.length < length) {
crypto.getRandomValues(randomBytes);
for (const byte of randomBytes) {
// Rejection sampling to avoid modulo bias
const limit = 256 - (256 % charCount);
if (byte < limit) {
result.push(charset[byte % charCount]);
}
if (result.length === length) break;
}
}
return result.join("");
}
// Entropy calculation
function passwordEntropy(length, charsetSize) {
return (length * Math.log2(charsetSize)).toFixed(1);
}
const pwd = securePassword(16);
console.log(pwd);
console.log(`Entropy: ${passwordEntropy(16, 72)} bits`);
// Example: Entropy: 98.0 bits
A 16-character password from a 72-character set gives ~98 bits of entropy — comfortably above the 80-bit threshold considered strong for most threat models.
The Modulo Bias Trap
Even developers who switch to crypto.getRandomValues() often introduce a subtle bug. Consider this naive approach:
// ❌ Modulo bias — some characters appear slightly more often
function naiveSecure(length) {
const chars = "abcdefghijklmnopqrstuvwxyz"; // 26 chars
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => chars[b % 26]).join("");
}
The issue: a Uint8 holds values 0–255. Since 256 is not evenly divisible by 26, the first 256 % 26 = 22 characters (a through v) get mapped to one extra byte value compared to the last four (w through z). Each of the first 22 characters appears with probability 10/256 instead of 9/256. The bias is small but measurable in an audit.
The fix is rejection sampling: discard any byte value that falls in the uneven tail, as shown in the securePassword implementation above. The limit computation 256 - (256 % charCount) gives you the highest multiple of charCount that fits in a byte, and you simply throw away values at or above that threshold.
Quick Reference
| Property | Math.random() |
crypto.getRandomValues() |
|---|---|---|
| Algorithm | xorshift128+ (PRNG) | OS entropy pool (CSPRNG) |
| Predictable? | Yes, with ~10 samples | No |
| Available | All environments | Browsers, Node ≥ 15 |
| Speed | Very fast | Fast enough for passwords |
| Suitable for passwords | Never | Always |
See It in Practice
If you want to see this approach running in a browser right now, this client-side password generator that uses crypto.getRandomValues correctly does exactly what is described above: all randomness is generated locally in your browser with the Web Crypto API, nothing is sent to a server.
Summary
-
Math.random()is a PRNG with a predictable internal state. Never use it for passwords, tokens, or any security-sensitive value. -
crypto.getRandomValues()draws from the OS entropy pool and is cryptographically secure. - Watch out for modulo bias: use rejection sampling when mapping random bytes to a charset.
- A 16-character password from a 72-character set yields ~98 bits of entropy, well above practical attack thresholds.
Next time you reach for Math.random(), ask yourself: does this value need to be unguessable? If yes, the answer is always crypto.getRandomValues().
Top comments (0)