Why Math.random() Will Get Your Users Hacked
Math.random() is not cryptographically secure. Learn why it fails for passwords, tokens, and IDs — and how to use crypto.getRandomValues() correctly.
Your Password Generator Uses a Broken Random Number Generator
There's a category of code that looks secure, passes code review, generates output that appears random, and ships to production protecting real user accounts — yet is fundamentally broken from a cryptographic perspective. That category is any security-sensitive randomness generated by Math.random().
I have personally audited three SaaS applications in the last year where the "generate secure password" feature, the session token generator, or the account recovery code system used Math.random() as the entropy source. The developers weren't careless. They wrote clean code, randomized character selection from a pool, and generated passwords that looked perfectly random to the naked eye. The problem: Math.random() is a pseudorandom number generator (PRNG) — its output is deterministic, predictable, and reconstructible by an attacker who observes enough output samples.
This article explains exactly why Math.random() fails for security applications, how the Web Crypto API's crypto.getRandomValues() solves the problem, and how to calculate whether your password or token system produces enough entropy to resist brute-force attacks.
Key Takeaways
Math.random()uses a PRNG (typically xorshift128+) that is deterministic — given enough output samples (~600 values), an attacker can reconstruct the internal state and predict all future outputs.crypto.getRandomValues()pulls entropy from the operating system's CSPRNG (ChaCha20 on Linux, BCryptGenRandom on Windows), which seeds from hardware noise sources.- A password needs at least 60 bits of entropy to resist online brute-force attacks, and 128 bits to resist offline cracking of leaked hashes.
- Entropy equals
log2(charset_size ^ password_length)— a 16-character password from a 94-character pool produces 104.9 bits. - UUIDs generated with
Math.random()have collision risk and are predictable — always usecrypto.randomUUID().
Why Math.random() Is Not Random Enough
Math.random() in V8 (Chrome, Node.js) uses the xorshift128+ algorithm. Every call produces a number derived from two 64-bit state values through a fixed sequence of XOR and shift operations. The operation is entirely deterministic:
// Simplified xorshift128+ — this is how Math.random() works internally
// The ENTIRE future output sequence is determined by state0 and state1
let state0 = /* initial seed */;
let state1 = /* initial seed */;
function xorshift128plus() {
let s1 = state0;
const s0 = state1;
state0 = s0;
s1 ^= s1 << 23n;
s1 ^= s1 >> 17n;
s1 ^= s0;
s1 ^= s0 >> 26n;
state1 = s1;
return state0 + state1;
}
// Math.random() normalizes this to [0, 1) — but the determinism remains
An attacker doesn't need the source code. Academic research (and publicly available tools like z3 and XorShift128Plus inverters) can reconstruct the internal state from approximately 600 observed output values. Once reconstructed, the attacker can predict every subsequent value — including your next password, the next session token, and the next recovery code.
Demonstration: Predicting Math.random() Output
// DEMONSTRATION ONLY — showing the predictability problem
// An attacker collects password generation outputs from your UI
// Your "secure" password generator (using Math.random — vulnerable)
function insecurePasswordGen(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%';
let password = '';
for (let i = 0; i < length; i++) {
// Each Math.random() call leaks information about PRNG state
password += charset[Math.floor(Math.random() * charset.length)];
}
return password;
}
// Attack scenario:
// 1. Attacker generates 50+ passwords from the same page session
// 2. Each password character reveals one Math.random() output
// 3. With ~600 outputs, attacker reconstructs xorshift128+ state
// 4. Attacker predicts every future password the generator will produce
// 5. If session tokens use the same Math.random(), those are predicted too
This isn't theoretical hand-wraving. In 2020, researchers demonstrated full state recovery of V8's xorshift128+ from browser-accessible Math.random() outputs, enabling prediction of subsequent values with 100% accuracy.
The Correct API: crypto.getRandomValues()
The Web Crypto API provides crypto.getRandomValues(), a CSPRNG (Cryptographically Secure Pseudo-Random Number Generator) backed by the operating system's entropy pool:
// ✅ SECURE — cryptographically strong random password generator
function generateSecurePassword(length = 16) {
const charset =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
const charsetLength = charset.length; // 94 characters
// Generate cryptographically random bytes
const randomBytes = new Uint8Array(length);
crypto.getRandomValues(randomBytes);
let password = '';
for (let i = 0; i < length; i++) {
// Modulo bias is negligible when charset (94) << byte range (256)
// For bias-free selection, use rejection sampling (shown below)
password += charset[randomBytes[i] % charsetLength];
}
return password;
}
// Bias-free version using rejection sampling
function generateUnbiasedPassword(length = 16) {
const charset =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
const charsetLength = charset.length; // 94
const maxValid = 256 - (256 % charsetLength); // 256 - (256 % 94) = 256 - 68 = 188
let password = '';
while (password.length < length) {
const byte = crypto.getRandomValues(new Uint8Array(1))[0];
if (byte < maxValid) { // Reject values that would cause bias
password += charset[byte % charsetLength];
}
}
return password;
}
console.log(generateSecurePassword(20));
// Output: "k$Rf9)mN2x!pL7&wQzT4" — each character independently random
Where the Entropy Comes From
| OS | CSPRNG Source | Entropy Seeds |
|---|---|---|
| Linux | /dev/urandom → ChaCha20 | Hardware interrupts, disk timing, network jitter, CPU RDRAND |
| Windows | BCryptGenRandom | TPM, CPU RDRAND, interrupt timing, system events |
| macOS | /dev/urandom → Fortuna | Hardware events, CPU RDRAND, SecureEnclave |
These CSPRNGs continuously reseed from hardware noise sources. Even if an attacker observes output, they cannot predict future output because the internal state is constantly mixed with fresh entropy. This is the fundamental difference: Math.random()'s state is static after initialization. crypto.getRandomValues()'s state is continuously refreshed with unpredictable physical noise.
Calculating Password Entropy: Is 12 Characters Enough?
Entropy measures the number of possible passwords an attacker must try in a brute-force attack. It's measured in bits:
Entropy (bits) = log2(charset_size ^ password_length)
= password_length × log2(charset_size)
// Calculate entropy for any password configuration
function calculateEntropy(charsetSize, length) {
const entropy = length * Math.log2(charsetSize);
const combinations = BigInt(charsetSize) ** BigInt(length);
// Time to brute-force at different speeds
const onlineAttack = Number(combinations / 1000n); // 1,000 guesses/sec
const offlineAttack = Number(combinations / 10000000000n); // 10 billion/sec (GPU)
return {
entropy: entropy.toFixed(1) + ' bits',
combinations: combinations.toLocaleString(),
onlineCrackSeconds: onlineAttack,
offlineCrackSeconds: offlineAttack
};
}
// Compare common configurations
console.table([
{ config: '8-char lowercase', ...calculateEntropy(26, 8) }, // 37.6 bits
{ config: '8-char mixed+digits', ...calculateEntropy(62, 8) }, // 47.6 bits
{ config: '12-char mixed+syms', ...calculateEntropy(94, 12) }, // 78.7 bits
{ config: '16-char mixed+syms', ...calculateEntropy(94, 16) }, // 104.9 bits
{ config: '20-char mixed+syms', ...calculateEntropy(94, 20) }, // 131.1 bits
]);
| Configuration | Entropy | Combinations | Offline Crack Time (10B/sec) |
|---|---|---|---|
| 8-char lowercase | 37.6 bits | 208 billion | 20 seconds |
| 8-char alphanumeric | 47.6 bits | 218 trillion | 6 hours |
| 12-char full charset | 78.7 bits | 4.7 × 10²³ | 1.5 million years |
| 16-char full charset | 104.9 bits | 3.7 × 10³¹ | 117 trillion years |
| 20-char full charset | 131.1 bits | 2.9 × 10³⁹ | Heat death of universe |
NIST SP 800-63B recommends passwords of at least 8 characters with no composition requirements (relying on breach-list checking instead), but from a pure entropy perspective, 16 characters from the full printable ASCII range provides 104.9 bits — well beyond the reach of any current or foreseeable brute-force capability.
Rather than calculating these numbers manually, use the ZamDev AI Password Generator to generate passwords using crypto.getRandomValues() with a real-time strength indicator showing entropy bits and estimated crack time for your configuration.
Secure Token Generation for Backend Systems
Passwords aren't the only security artifact that needs cryptographic randomness. Session tokens, API keys, CSRF tokens, and email verification codes all need unpredictable values:
// Node.js — generating secure tokens server-side
const crypto = require('crypto');
// Session token — 32 bytes = 256 bits of entropy
function generateSessionToken() {
return crypto.randomBytes(32).toString('hex'); // 64 hex characters
}
// API key — URL-safe base64 encoding
function generateAPIKey() {
return crypto.randomBytes(32).toString('base64url'); // 43 characters
}
// Short verification code (6 digits) — for email/SMS verification
function generateVerificationCode() {
// Rejection sampling to avoid modulo bias
let code;
do {
const bytes = crypto.randomBytes(4);
code = bytes.readUInt32BE(0) % 1000000;
} while (bytes.readUInt32BE(0) >= 4294000000); // reject to prevent bias
return code.toString().padStart(6, '0');
}
// CSRF token — tied to session
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
Hashing Passwords: Never Store Plaintext
Passwords must be hashed before storage. Use bcrypt, scrypt, or Argon2 — never raw SHA-256:
const bcrypt = require('bcrypt');
// Hash password before storing — cost factor 12 is the current recommendation
async function hashPassword(plaintext) {
const saltRounds = 12; // 2^12 = 4,096 iterations
return bcrypt.hash(plaintext, saltRounds);
}
// Verify password on login
async function verifyPassword(plaintext, storedHash) {
return bcrypt.compare(plaintext, storedHash);
}
// ❌ NEVER DO THIS — raw SHA-256 is fast, attackers crack billions/sec
// const hash = crypto.createHash('sha256').update(password).digest('hex');
Raw SHA-256 processes 10 billion hashes per second on a modern GPU. A 12-character password with 78.7 bits of entropy sounds strong — but at 10 billion operations per second, SHA-256 wouldn't even slow down a determined attacker. bcrypt with cost factor 12 processes approximately 3 hashes per second per CPU core — making brute force computationally infeasible.
For quick hash generation during development — generating SHA-256 checksums for file integrity verification, creating test hashes, or learning how different algorithms produce different digest lengths — use the ZamDev AI Hash Generator. It produces cryptographic hashes via the Web Crypto API entirely in your browser.
Common Pitfalls and Troubleshooting
"My crypto.getRandomValues() call throws in a web worker"
crypto.getRandomValues() is available in web workers, service workers, and the main thread in all modern browsers. If it throws, you're likely running in an insecure context (HTTP instead of HTTPS). The Web Crypto API requires a secure context — either HTTPS or localhost.
"I need deterministic random values for testing"
Use Math.random() with a seeded PRNG only for testing. Libraries like seedrandom let you seed a PRNG for reproducible test output. Never use seeded PRNGs in production for security-sensitive values.
"UUID collisions are happening in my database"
If you're generating UUIDs with Math.random() instead of crypto.randomUUID(), the limited PRNG state space makes collisions far more likely than the theoretical UUID collision probability. Switch to crypto.randomUUID() — it uses the same CSPRNG backend as crypto.getRandomValues() and provides true 122-bit entropy.
"My password meter says strong but the password is weak"
Most client-side password meters measure visual complexity (length + character classes), not actual entropy. A password like P@ssw0rd! scores high on most meters but exists in every breach wordlist. True strength comes from randomness, not complexity rules. A randomly generated 16-character string from a CSPRNG is orders of magnitude stronger than a "complex" human-chosen password.
"How do I audit whether my codebase uses Math.random() for security?"
Grep for it:
# Find every Math.random() usage in your codebase
grep -rn "Math.random" --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx" .
# Check if any appear near security-related functions
grep -rn "Math.random" --include="*.js" --include="*.ts" . | grep -i "token\|password\|secret\|key\|session\|nonce\|salt\|csrf\|uuid\|id"
Every match near a security-related context needs to be replaced with crypto.getRandomValues() or crypto.randomUUID().
The gap between "looks random" and "is cryptographically random" is invisible to humans but fully exploitable by attackers. Math.random() isn't broken for shuffling a playlist or generating a game map. It is broken for protecting user accounts, generating session tokens, or creating any value that an adversary might try to predict. Use the right tool for the right job — your users' security depends on which random number generator you picked.