DEV Community

ricco020
ricco020

Posted on

Why Math.random() is unsafe for passwords — and how to use crypto.getRandomValues instead

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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("");
}
Enter fullscreen mode Exit fullscreen mode

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)