The Math.random() Problem
When I first built the password generator for this site, I reached for Math.random() without thinking twice. It's the default tool every JavaScript developer learns first. Generate a random number, pick a character — what could go wrong? Everything, it turns out. Math.random() is a Pseudo-Random Number Generator (PRNG). In V8 (the JavaScript engine behind Chrome and Node.js), it uses an algorithm called xorshift128+. This algorithm is fast and produces numbers that look random enough for games, animations, and shuffling a playlist. But it has a fatal flaw: given enough output, you can reverse-engineer the internal state and predict every future value. Researchers have demonstrated that with as few as a handful of consecutive Math.random() outputs, the entire internal state of xorshift128+ can be reconstructed. That means if an attacker can observe or guess a few random values from your generator, they can predict every password it will ever produce. For a dice game or a randomized UI animation, this doesn't matter. For generating passwords that protect real accounts? It's a security vulnerability.
Real-World Consequence
In 2015, researchers demonstrated a practical attack on Math.random() in browser-based CSRF tokens. They recovered the PRNG state from observable outputs and predicted subsequent tokens. The same class of attack applies to any security-sensitive value generated with Math.random().
// DON'T do this for passwords!
function insecurePassword(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result; // Predictable output!
}Web Crypto API to the Rescue
The fix is straightforward: replace Math.random() with crypto.getRandomValues(). This single change transforms password generation from theoretically predictable to cryptographically secure. crypto.getRandomValues() is part of the Web Crypto API, available in all modern browsers (and Node.js via the crypto module). Unlike Math.random(), it's a CSPRNG — a Cryptographically Secure Pseudo-Random Number Generator. The difference is fundamental: it draws entropy from your operating system's secure random source. On Linux and macOS, that's /dev/urandom, which collects entropy from hardware interrupts, disk timing, and other unpredictable physical events. On Windows, it's BCryptGenRandom. The implementation I used in the password generator does three important things. First, it creates a typed array of Uint32 values and fills it with cryptographically secure random numbers in a single call. Second, it guarantees that at least one character from each selected category (uppercase, lowercase, numbers, symbols) appears in the result — otherwise users would occasionally get passwords missing a required type. Third, it shuffles the result using a Fisher-Yates shuffle, and critically, the shuffle itself uses crypto.getRandomValues() for its random indices. That last point is something I initially missed. In my first refactor, I used crypto for the character selection but Math.random() for the shuffle. A code reviewer pointed out that this partially undermined the security — if the shuffle is predictable, the positions of the guaranteed characters become predictable too. Lesson learned: when security matters, every source of randomness in the pipeline needs to be cryptographic.
// The actual pattern from our Password Generator
const array = new Uint32Array(length);
crypto.getRandomValues(array);
const result: string[] = [];
// Guarantee at least one char from each selected category
for (let i = 0; i < required.length && i < length; i++) {
const reqChars = required[i];
result.push(reqChars[array[i] % reqChars.length]);
}
// Fill the rest from the full character set
for (let i = result.length; i < length; i++) {
result.push(chars[array[i] % chars.length]);
}
// Fisher-Yates shuffle — also using crypto.getRandomValues
const shuffleArray = new Uint32Array(result.length);
crypto.getRandomValues(shuffleArray);
for (let i = result.length - 1; i > 0; i--) {
const j = shuffleArray[i] % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}Browser Support
crypto.getRandomValues() is supported in all modern browsers — Chrome, Firefox, Safari, Edge — and has been since 2013. There's no reason to use Math.random() for security-sensitive operations in any browser context today. In Node.js, import { randomBytes } from 'crypto' provides the equivalent functionality.
Password Entropy Math
Understanding entropy helps explain why certain password configurations are strong and others are weak. Entropy measures the number of bits of randomness in a password, calculated as: Entropy = L * log2(N) Where N is the number of possible characters (the character set size) and L is the password length. Each bit of entropy doubles the number of possible passwords an attacker must try. With only lowercase letters (N=26), a 12-character password gives you about 56 bits of entropy. Add uppercase (N=52) and you get 68 bits. Include numbers and symbols (N=95) and you jump to 78 bits. Increase the length to 16 characters with all types and you hit 105 bits — enough to resist brute-force attacks for billions of years with current technology. This is why our generator defaults to 16 characters with all four character types enabled. It's the sweet spot: strong enough for any purpose, short enough to be practical. NIST Special Publication 800-63B, the gold standard for digital identity guidelines, recommends a minimum of 8 characters for user-chosen passwords. But the key word is 'minimum.' For randomly generated passwords (which is what our tool produces), 12 characters with a mixed character set is the practical floor, and 16 or more is what I recommend.
Entropy = log2(N^L) = L * log2(N)
Where:
N = size of character set
L = password length
Examples:
12 chars, lowercase only (26): 12 * 4.7 = 56.4 bits
12 chars, mixed + numbers (62): 12 * 5.95 = 71.4 bits
16 chars, all types (95): 16 * 6.57 = 105.1 bits
20 chars, all types (95): 20 * 6.57 = 131.4 bitsNIST SP 800-63B Guidelines
NIST no longer recommends forced password rotation or mandatory complexity rules for user-chosen passwords. Instead, they recommend checking passwords against known-breached databases, supporting long passwords (up to 64+ characters), and focusing on length over complexity. For randomly generated passwords, length and character set diversity together determine strength.
The UX Challenge
Building a secure password generator is the easy part. Making it usable is where the real challenge lies. The first UX problem I hit was the strength indicator. My initial version was binary: 'weak' or 'strong.' Users had no idea what to aim for. I replaced it with a four-level system — weak, fair, good, strong — with a color-coded progress bar and shield icons (ShieldX, ShieldAlert, Shield, ShieldCheck from Lucide). The visual feedback loop is immediate: drag the length slider and watch the bar fill up, change the character types and see the strength shift. The strength evaluation considers both length and the number of character types selected. A 16-character password with 3+ types gets 'strong.' A 12-character password with 2+ types gets 'good.' Below 8 characters is always 'weak,' regardless of complexity. This matches the entropy math — length matters more than complexity, but both contribute. One feature request I initially dismissed but later added was copy-to-clipboard with visual feedback. The original version just had a generate button. But in practice, the entire workflow is: generate, copy, paste into a signup form. The copy button with a brief checkmark animation (using a 2-second timeout to reset the state) reduced friction enormously. I also added bulk generation — the ability to generate 5, 10, 15, or 20 passwords at once. This seemed like a niche feature, but it turned out to be popular with developers provisioning multiple test accounts or creating passwords for team members. Each bulk password gets its own copy button with independent state tracking. One thing I chose not to add (yet) was an exclude-similar-characters option (filtering out 0/O, 1/l/I). While it improves readability, it reduces the character set size and therefore entropy. For a tool that emphasizes security, I decided that maximum entropy at default settings was the right tradeoff. Users who need to manually type passwords can reduce the length and exclude symbols instead.
function evaluateStrength(
password: string,
options: { uppercase: boolean; lowercase: boolean; numbers: boolean; symbols: boolean }
): StrengthLevel {
const length = password.length;
const typesUsed = [options.uppercase, options.lowercase,
options.numbers, options.symbols].filter(Boolean).length;
if (length < 8) return "weak";
if (length < 12 && typesUsed < 3) return "fair";
if (length >= 16 && typesUsed >= 3) return "strong";
if (length >= 12 && typesUsed >= 2) return "good";
return "fair";
}Lesson Learned: UX Over Purity
My first version generated passwords and nothing else — no strength indicator, no copy button, no visual feedback. It was technically correct but practically useless. The lesson: security tools must be pleasant to use, or people won't use them at all. Every UX improvement I added — the strength bar, the copy animation, the bulk generator — increased actual usage.
Never Trust Math.random() for Security
The core takeaway is simple: Math.random() is a general-purpose PRNG, not a security primitive. It was never designed for cryptographic use, and using it for passwords, tokens, or any security-sensitive value is a vulnerability waiting to be exploited. The Web Crypto API exists specifically for this purpose. crypto.getRandomValues() is available in every modern browser, draws from OS-level entropy, and provides cryptographically secure randomness with no performance penalty for typical use cases. When I rebuilt the password generator, the actual code change was small — replacing Math.random() with crypto.getRandomValues() and adjusting the array handling. The conceptual shift was bigger: thinking about every source of randomness in the system, including the Fisher-Yates shuffle that distributes characters. Security is a pipeline, and the pipeline is only as strong as its weakest link. If you're building any tool that generates security-sensitive values — passwords, tokens, nonces, session IDs, encryption keys — start with crypto.getRandomValues(). Not Math.random(). Not ever.
Sources & Further Reading
Try the Secure Password Generator
Generate cryptographically secure passwords using Web Crypto API. All character types, strength indicators, bulk generation — 100% in your browser, nothing stored.
Generate Secure Passwords