Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Building a Password Generator in JavaScript, Correctly

Sean

Platform Writer

Aug 14, 2026
8 min read

Nearly every password generator tutorial online uses Math.random, and every one of them is wrong. Math.random is not cryptographically secure, its output is predictable from previous outputs, and it must never be used to generate anything secret. The correct primitive is crypto.getRandomValues, it is available in every browser and in Node, and using it is barely more work.

Building a Password Generator in JavaScript, Correctly

This is a small project with one genuinely important detail and one subtle one. The important detail is which random source you use. The subtle one is modulo bias, which quietly makes some characters more likely than others even when the random source is perfect.

Table of contents

Why Math.random is disqualified

Math.random is a pseudorandom number generator optimised for speed. It produces a deterministic sequence from an internal state, and the specification explicitly makes no security guarantees.

The practical consequence is that an observer with enough consecutive outputs can recover the internal state and predict every subsequent value. This has been demonstrated repeatedly against the generators in real JavaScript engines. If your generator produces passwords in a loop, an attacker who obtains one can derive the others.

The Web Crypto API provides the alternative, drawing from the operating system’s entropy pool.

// Wrong. Never for anything secret.
const n = Math.floor(Math.random() * 62);

// Right. Cryptographically secure.
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const n = buf[0] % 62;   // still biased -- see below

crypto.getRandomValues is available in all modern browsers and in Node 15 and later as globalThis.crypto. In secure contexts only, which means HTTPS or localhost, so a page served over plain HTTP on a remote host will find crypto undefined.

That last point causes a confusing bug: the generator works on localhost and throws in production if the site is not on HTTPS. It should be on HTTPS anyway, and this is one more reason.

Modulo bias, the part tutorials skip

Even with a perfect random source, taking a remainder introduces bias unless the range divides evenly.

Consider drawing a byte from 0 to 255 and reducing modulo 62 for an alphanumeric alphabet. 256 is not a multiple of 62: values 0 to 7 can be produced by five different bytes while values 8 to 61 come from four. The first eight characters of your alphabet are about 25 percent more likely than the rest.

For a single character that is a small weakness. Across every password your application generates it is a systematic reduction in entropy, and it is entirely avoidable.

The fix is rejection sampling: discard values that fall in the uneven tail and draw again.

function randomIndex(max) {
  // Largest multiple of max that fits in 32 bits.
  const limit = Math.floor(0xffffffff / max) * max;
  const buf = new Uint32Array(1);

  let value;
  do {
    crypto.getRandomValues(buf);
    value = buf[0];
  } while (value >= limit);   // reject the biased tail

  return value % max;
}

The rejection rate is tiny, because the discarded range is small relative to 2^32, so the loop essentially never runs twice. It costs nothing and removes the bias entirely.

A complete generator

const SETS = {
  lower: "abcdefghijklmnopqrstuvwxyz",
  upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
  digits: "0123456789",
  symbols: "!@#$%^&*()-_=+[]{};:,.<>?",
};

function generatePassword({
  length = 20,
  lower = true,
  upper = true,
  digits = true,
  symbols = true,
  excludeAmbiguous = false,
} = {}) {
  const chosen = [];
  if (lower) chosen.push(SETS.lower);
  if (upper) chosen.push(SETS.upper);
  if (digits) chosen.push(SETS.digits);
  if (symbols) chosen.push(SETS.symbols);

  if (chosen.length === 0) throw new Error("Select at least one character set");
  if (length < chosen.length) throw new Error("Length is shorter than the number of sets");

  let pool = chosen.join("");
  if (excludeAmbiguous) {
    pool = pool.replace(/[Il1O0]/g, "");
  }

  // Guarantee one character from each selected set.
  const chars = chosen.map((set) => set[randomIndex(set.length)]);

  while (chars.length < length) {
    chars.push(pool[randomIndex(pool.length)]);
  }

  return shuffle(chars).join("");
}

Two things there are easy to get wrong. Guaranteeing one character from each set is a common requirement, and the naive implementation puts those characters at the start of the string in a fixed order, which is a real pattern an attacker can exploit. Hence the shuffle.

And the shuffle itself must be unbiased. A comparator returning a random value is the classic wrong answer: it produces a distribution that is measurably non-uniform and depends on the engine’s sort implementation.

// Fisher-Yates, using the same secure source.
function shuffle(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = randomIndex(i + 1);
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

// Wrong: biased, and engine-dependent.
// array.sort(() => Math.random() - 0.5);

Length beats complexity

The entropy of a generated password is length multiplied by the base-two logarithm of the alphabet size. Running the numbers settles the perennial argument about character requirements.

  • 12 characters, lowercase only: about 56 bits.
  • 12 characters, full 94-character printable set: about 79 bits.
  • 16 characters, lowercase only: about 75 bits.
  • 20 characters, lowercase and digits: about 103 bits.
  • 20 characters, full set: about 131 bits.

Note that 16 lowercase characters beats 12 mixed characters. Length contributes more than alphabet size, because it multiplies rather than adds, and longer passwords are easier to type and less likely to be mangled by a system that silently truncates.

Anything above roughly 80 bits is beyond brute force against a properly hashed password. Above 128 bits the number stops being meaningful and you are protecting against nothing that exists.

function entropyBits(length, poolSize) {
  return Math.round(length * Math.log2(poolSize));
}

Show that number in the interface. It is more informative than a coloured strength bar, which for a randomly generated password is measuring nothing useful anyway since those heuristics are designed for human-chosen passwords.

Things not to do in the interface

The generator is the easy part. These are the mistakes that undermine it.

  • Do not send the password anywhere. Generation is entirely client-side, and a generator that calls an API to produce a password has put it in a request log.
  • Do not store it in localStorage, sessionStorage, or a cookie. It persists, and any script on the page can read it.
  • Do not put it in the URL, as a query parameter or a fragment. It lands in history, in referrer headers, and in server logs.
  • Do not log it, even in development. Development consoles get screenshotted and shared.
  • Do use the async clipboard API for copying, and clear the clipboard after a delay if you can.
  • Do use type=password with a visibility toggle rather than displaying it in plain text by default.

If the generator is part of a signup form and you are also handling the password server-side, the rules there are separate and short: hash with bcrypt, scrypt, or Argon2, never with a general-purpose hash like SHA-256, and never store or log the plaintext at any point in the request.

The other thing worth doing in a signup context is not imposing composition rules on user-chosen passwords. Requiring a symbol and a capital produces predictable substitutions rather than entropy. A minimum length and a check against known breached passwords is a far better policy.

How this fits the rest of the stack

A generator like this is a static page: HTML, CSS, and a script that never talks to a server, which is exactly the shape that should be built from a repository and served as static files. There is no runtime to fail and nothing to keep patched. The RunxBuild hosting calculator covers static sites and services in the same view, and static hosting includes 120GB of bandwidth before any per-gigabyte charge applies.

Useful related references:

FAQ

Why should I not use Math.random for passwords?

It is a pseudorandom generator with no security guarantees, producing a deterministic sequence from internal state that can be recovered from enough outputs. Use crypto.getRandomValues, which draws from the operating system’s entropy pool.

What is modulo bias in a password generator?

Reducing a random number with a remainder skews the distribution when the range does not divide evenly, making early characters in the alphabet more likely. Fix it with rejection sampling: discard values in the uneven tail and draw again.

How long should a generated password be?

At least 16 characters, and 20 or more where the system allows. Length contributes more entropy than alphabet size, so 16 lowercase characters beats 12 mixed ones and is easier to handle.

Why is crypto undefined in my browser?

The Web Crypto API is only available in secure contexts, meaning HTTPS or localhost. A page served over plain HTTP on a remote host will not have it, which is why generators often work locally and fail in production.

Is it safe to generate passwords in the browser?

Yes, provided generation is entirely client-side with a secure random source and the password is never sent anywhere, stored in browser storage, placed in a URL, or logged.

#Password Generator JavaScript#Web Crypto API#getRandomValues#JavaScript Security#Modulo Bias