DEV Community

xiaopeng
xiaopeng

Posted on

I built a password generator that never sends your data anywhere — here's the crypto behind it

Try it here → https://mypasswordgen.com (no signup, works offline once loaded)

Every "free password generator" website I've used had the same quiet problem: I have no idea what happens to the password after it's generated. Some sites send it to their server. Some embed analytics everywhere. And almost all of them ask you to trust them, without showing any proof.

So as a side project, I built SecurePass Gen — a password generator where every single line of logic runs in your browser. Open DevTools, watch the Network tab: you'll see zero requests when you click "Generate". The password never leaves your device, because there's no code on a server to send it to.

In this post I want to share the three cryptographic details that were more interesting than I expected.


1. Math.random() is not random enough — and fixing that isn't as simple as crypto.getRandomValues()

The classic mistake in password generators is using Math.random(). It's a PRNG seeded with something predictable — it's fine for games, disastrous for security. The browser's answer is the Web Crypto API:

const buf = new Uint8Array(1);
crypto.getRandomValues(buf); // cryptographically secure
Enter fullscreen mode Exit fullscreen mode

But here's the trap most tutorials skip: modulo bias.

You need a random integer in a range like [0, 26) for letters. The naive approach:

crypto.getRandomValues(buf) % 26; // ❌ biased
Enter fullscreen mode Exit fullscreen mode

A byte has 256 possible values. 256 % 26 = 22, so the first 22 letters are slightly more likely to appear than the last 4. Each character individually is only slightly biased — but across a 16-character password, the bias compounds and shrinks your real entropy.

The fix is rejection sampling — discard values that would skew the distribution:

function secureRandomInt(max: number): number {
  const range = 256 - (256 % max); // largest multiple of max in [0, 256)
  const buf = new Uint8Array(1);
  let r: number;
  do {
    crypto.getRandomValues(buf);
    r = buf[0];
  } while (r >= range); // reject and retry → uniform distribution
  return r % max;
}
Enter fullscreen mode Exit fullscreen mode

Now every character in the pool is exactly equally likely. Boring detail, real security impact.

2. "At least one of each character type" — without revealing where they are

Users expect that checking "uppercase + digits + symbols" guarantees at least one of each appears. The common implementation prepends one required character per class... which leaks structure: the first 4 characters are always one of each type. An attacker who knows your generator can exploit that.

My approach:

  1. Pick one random character from each selected class (using the unbiased RNG above)
  2. Fill the remaining slots from the full combined pool
  3. Shuffle everything with a cryptographically secure Fisher-Yates — so the required characters end up at unpredictable positions
function shuffle<T>(arr: T[]): T[] {
  const result = [...arr];
  for (let i = result.length - 1; i > 0; i--) {
    const j = secureRandomInt(i + 1); // crypto-secure, not Math.random
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

One subtlety: the shuffle must also use the CSPRNG. A Fisher-Yates driven by Math.random() is still a biased shuffle — I see this bug in a lot of open-source generators.

3. Honest entropy math beats a fake "strength meter"

Most strength meters check "does it have a symbol? +10 points!" — theater, not math. Entropy is measurable:

entropy (bits) = length × log₂(charsetSize)
Enter fullscreen mode Exit fullscreen mode

A 16-character password from a 96-character pool: 16 × log₂(96) ≈ 105 bits. At a pessimistic 10 billion guesses/second (offline attack on a fast hash), that's ~10²¹ years.

But raw entropy lies about human passwords. P@ssw0rd123! has decent theoretical entropy and is still garbage — it's in every cracking wordlist. So on top of the entropy calculation I apply penalties for known-bad patterns:

const commonPatterns = [
  'password', '123456', 'qwerty', 'abc123', 'letmein',
  'admin', 'welcome', 'iloveyou', 'monkey', 'dragon',
];
// pattern match: -30 bits; triple repeats: -10; sequences (abcd/1234): -10
Enter fullscreen mode Exit fullscreen mode

The generator also offers a passphrase mode (XKCD #936 style — Brave-Tiger-Storm-42) for people who need to actually type their password.


What I'd do differently

  • A real strength checker should ship a compressed common-password list and do actual dictionary lookups, not substring matching
  • I'd like to add a Web Worker so generation can never block the UI on very long lengths
  • Maybe an offline PWA version

If you want to poke at it: mypasswordgen.com — it's free, no account, no tracking, and everything is computed locally.

Feedback welcome — especially from anyone who spots a weakness in the crypto approach. That's the whole point of writing this up.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.