DEV Community

Cover image for Math.random() Is Not Enough: Generating Truly Secure Passwords in JavaScript
Muhammad Imran
Muhammad Imran

Posted on

Math.random() Is Not Enough: Generating Truly Secure Passwords in JavaScript

Most password generators you find in tutorials share the same flaw. They call Math.random(), pick characters from a string, and call it a day. The passwords look random. They are not random enough.

This matters because a predictable generator is barely better than no generator at all. If someone can guess the internal state of your random source, they can reproduce every "random" password it ever produced.

Why Math.random() is not safe

Math.random() is a pseudorandom number generator. It starts from a seed and produces a long but fully deterministic sequence. In V8 (the engine behind Chrome and Node.js) the algorithm is xorshift128+. It is fast and fine for games, shuffling a playlist, or picking a random quote.

It is not fine for security, for three reasons:

  1. It is predictable. Given enough consecutive outputs, the internal state can be reconstructed, and every future value becomes known. Researchers have demonstrated this repeatedly against real-world token generators.
  2. It is not seeded by you. You cannot give it a strong seed, and you cannot verify the quality of the seed the engine chose.
  3. MDN says so directly. The documentation warns that Math.random() does not provide cryptographically secure random numbers and must not be used for anything security related.

Passwords, reset tokens, session IDs, and API keys are all security related.

What to use instead

Browsers and Node.js ship a cryptographic random source: the Web Crypto API.

const array = new Uint32Array(4);
crypto.getRandomValues(array);
console.log(array); // four unpredictable 32-bit numbers
Enter fullscreen mode Exit fullscreen mode

crypto.getRandomValues() pulls from the operating system's entropy pool, the same source used for TLS keys. It is available in every modern browser and in Node.js 19+ as a global (earlier via require('crypto').webcrypto).

For single IDs, crypto.randomUUID() gives you a v4 UUID in one call:

const id = crypto.randomUUID();
// "3f6d4a2e-9b1c-4d5f-8a2e-7c9d1b3f5a6e"
Enter fullscreen mode Exit fullscreen mode

The math that actually matters: entropy

A password's strength is measured in bits of entropy. The formula is simple:

entropy = length x log2(charset size)
Enter fullscreen mode Exit fullscreen mode

Some concrete numbers:

  • 8 lowercase letters: 8 x 4.7 = ~38 bits. Crackable in hours on a single GPU.
  • 12 characters from letters, digits, and symbols (94 chars): 12 x 6.55 = ~79 bits. Out of reach for brute force.
  • 16 characters from the same set: ~105 bits. Comfortable margin for years.

Length beats cleverness. A 16-character password from a plain generator is stronger than an 8-character one full of symbols that you struggle to remember. This is also why passphrases work: four random words from a 7,776-word list give about 51 bits, and they are easy to type.

Building a secure generator

Here is a compact generator that does it right:

function securePassword(length = 16) {
  const charset =
    "abcdefghijklmnopqrstuvwxyz" +
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
    "0123456789" +
    "!@#$%^&*()-_=+[]{};:,.<>?";

  const randomValues = new Uint32Array(length);
  crypto.getRandomValues(randomValues);

  let password = "";
  for (let i = 0; i < length; i++) {
    // modulo bias is negligible here for typical lengths,
    // but rejection sampling removes it entirely
    password += charset[randomValues[i] % charset.length];
  }
  return password;
}

console.log(securePassword()); // e.g. "kQ7$mZ2@pL9#vX4!"
console.log(securePassword(20));
Enter fullscreen mode Exit fullscreen mode

A few notes on this code:

  • crypto.getRandomValues() fills the whole array in one call, which is faster than calling it per character.
  • The % charset.length introduces a tiny modulo bias because 2^32 is not a multiple of 91. For password generation this bias is negligible, but if you want it gone, discard values above the largest multiple of the charset size and draw again.
  • The function runs entirely client-side. The password never leaves the browser, which is exactly how a generator should behave.

If you want to go further, add options users actually ask for: exclude ambiguous characters (0, O, l, 1), guarantee at least one character from each group, or generate a memorable passphrase from a word list instead.

Mistakes to avoid

  • Seeding with Date.now(). Timestamps are guessable to the millisecond. An attacker who knows roughly when a password was created can brute force the seed.
  • Using Math.random() for reset tokens or API keys. Same predictability problem, worse consequences, because tokens are often transmitted and logged.
  • Short lengths with big charsets. Eight characters is short no matter how exotic the symbols are. Default to 16 or more.
  • Rolling your own crypto. You do not need a custom shuffling algorithm. The platform already gives you a vetted one.

The one-line version

If you just need a strong password right now and do not want to write code, there is a free client-side password generator at https://handynest.live/tools/password-generator.html that uses this same approach. Nothing is sent to a server.

Takeaway

Random-looking is not random. Math.random() is a fast PRNG for non-security tasks. For passwords, tokens, and keys, reach for crypto.getRandomValues(), make the output long enough to carry real entropy, and keep the whole thing client-side. It is about ten lines of code, and it is the difference between a password that looks strong and one that actually is.

Top comments (0)