What Actually Happens Inside a Password Generator
16 June, 2026 Security
A while back I reviewed a password generator a team had shipped inside an internal tool. It looked fine — nice UI, sliders for length, checkboxes for character classes. Then I read the source and found Math.random() at the heart of it. Every password that tool had ever generated was predictable to anyone who cared to reconstruct the PRNG state. Nobody had reviewed it because it worked — it produced password-shaped strings, and password-shaped is not the same as random. That afternoon taught me that a password generator is one of those deceptively simple programs where the obvious implementation is quietly broken in three different ways.
This article is the one I wrote after that review. It is about what a generator actually has to do correctly, and why each step is less trivial than it looks. If you want the maths on how much entropy a given length and character set buys you, that is the password entropy guide — here I assume the bits are decided and focus on producing them without bias. The same correctness principles drive the browser-based password generator; this is what is happening when you move the slider.
Step One: The Randomness Source
Everything rests on this, and it is where most broken generators break.
A password generator needs a cryptographically secure pseudo-random number generator (CSPRNG). The distinction from an ordinary PRNG is not academic:
| Property | Ordinary PRNG (Math.random, rand) |
CSPRNG |
|---|---|---|
| Seed | System clock or small state | OS entropy pool |
| Predictability | State recoverable from outputs | Computationally infeasible to predict |
| Purpose | Simulations, games, shuffling cards | Keys, tokens, passwords |
Math.random(), PHP's rand()/mt_rand(), and Python's random module are all Mersenne Twister or similar — fast, statistically uniform, and completely unsuitable for secrets. Observe a handful of outputs and you can recover the internal state and predict every future value. For a card game, who cares. For a password, that is the whole ballgame.
The correct sources are the OS-backed CSPRNGs: random_int() in PHP, secrets in Python, crypto.getRandomValues() in browsers, crypto.randomInt() in Node. Use them and nothing else. This is the same primitive that underpins diceware passphrase generation — words instead of characters, identical randomness requirement.
Step Two: Building the Character Set
This part looks trivial — concatenate the enabled classes — but there is a subtlety. The character set is an array, and the password is a sequence of indices into it. The quality of the password depends entirely on those indices being uniformly distributed over [0, length-of-set). Which brings us to the bug almost everyone writes.
Step Three: Modulo Bias, the Bug Almost Everyone Writes
Here is the naive way to pick a character:
// WRONG - introduces modulo bias
const arr = new Uint32Array(1);
crypto.getRandomValues(arr);
const index = arr[0] % charset.length;
crypto.getRandomValues() gives you a uniform integer in [0, 2^32). But 2^32 is almost never an exact multiple of charset.length. Say your set has 70 characters. 2^32 = 4,294,967,296, and 4,294,967,296 % 70 = 16. That means the first 16 characters of your set are reachable by one more underlying value than the rest. They come up slightly more often. The distribution is skewed, and an attacker who knows your charset can exploit that skew to prune their search space.
The bias is small, but it is free to avoid, so there is no excuse. The fix is rejection sampling: discard the handful of raw values that fall in the biased tail, and only accept values from the largest range that is an exact multiple of the set size.
function secureRandomInt(max) {
const range = 0x100000000; // 2^32
const limit = range - (range % max); // largest exact multiple of max
const arr = new Uint32Array(1);
let val;
do {
crypto.getRandomValues(arr);
val = arr[0];
} while (val >= limit); // reject the biased tail
return val % max;
}
The loop almost never runs more than once — the rejected tail is tiny — but it guarantees a perfectly uniform result. Higher-level helpers do this for you: random_int() in PHP and secrets.randbelow() in Python handle rejection internally, which is exactly why you should prefer them over rolling your own modulo.
Step Four: The "One of Each" Trap
Most generators offer "include at least one uppercase, one digit, one symbol". This is where a correct-looking implementation leaks entropy in a way that is genuinely easy to miss.
The naive approach: generate the password, then if it happens to lack a digit, overwrite the last character with a random digit. Two problems. First, you have just made the last position more likely to be a digit than any other — a detectable pattern. Second, if you loop "regenerate until it contains one of each", you are fine on correctness but can waste cycles on short passwords with many required classes.
The clean approach is to guarantee the required characters up front, then shuffle:
- Place one random character from each required class into the password.
- Fill the remaining positions from the full combined set.
- Shuffle the whole thing with a cryptographically secure Fisher-Yates shuffle so the guaranteed characters are not stuck at fixed positions.
The shuffle is the step people forget, and it is the one that matters — without it, position 0 is always uppercase and position 1 is always a digit, which is exactly the kind of structure attacker rule-sets feed on. And the shuffle's swaps must use the CSPRNG too, not Math.random(), or you have reintroduced the original sin at the last moment.
One honest caveat: forcing "one of each" very slightly reduces entropy versus drawing every character freely from the full set, because you have constrained the space. For any reasonable length the reduction is negligible, and it is usually worth it to satisfy a complexity policy — but it is a trade, not a free upgrade. If a system lets you, a longer password drawn freely beats a shorter one with forced classes, which is the same conclusion the entropy article reaches from the maths.
Putting It Together
Here is a complete, correct generator in three languages. Note how little code it is once the randomness is right — the difficulty was never volume, it was the three subtle traps above.
PHP
<?php
declare(strict_types=1);
function generatePassword(int $length = 20, string $charset = ''): string
{
$charset = $charset ?: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
$max = strlen($charset) - 1;
// random_int() is a CSPRNG and is rejection-sampled internally - no modulo bias
$chars = array_map(
static fn (): string => $charset[random_int(0, $max)],
range(1, $length),
);
return implode('', $chars);
}
echo generatePassword(20);
Python
import secrets
import string
def generate_password(length: int = 20) -> str:
charset = string.ascii_letters + string.digits + '!@#$%^&*'
# secrets.choice() uses os.urandom and is bias-free
return ''.join(secrets.choice(charset) for _ in range(length))
print(generate_password(20))
JavaScript (browser), with guaranteed classes
function secureRandomInt(max) {
const range = 0x100000000;
const limit = range - (range % max);
const arr = new Uint32Array(1);
let val;
do { crypto.getRandomValues(arr); val = arr[0]; } while (val >= limit);
return val % max;
}
function secureShuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1); // CSPRNG-backed swaps
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function generatePassword(length = 20) {
const classes = {
lower: 'abcdefghijklmnopqrstuvwxyz',
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digit: '0123456789',
symbol: '!@#$%^&*',
};
const all = Object.values(classes).join('');
// one guaranteed character per class, then fill, then shuffle
const chars = Object.values(classes).map(set => set[secureRandomInt(set.length)]);
while (chars.length < length) {
chars.push(all[secureRandomInt(all.length)]);
}
return secureShuffle(chars).join('');
}
console.log(generatePassword(20));
Four Decisions, Three Traps
A password generator is four correctness decisions, and three of them are easy to get subtly wrong: use a CSPRNG and never an ordinary PRNG; eliminate modulo bias with rejection sampling (or use a helper that already does); and if you force character classes, seed them up front and shuffle securely rather than patching the result. None of it is much code. All of it is invisible when it is wrong — the output still looks like a password. That is exactly why generators ship broken, and why "it produces random-looking strings" is never the bar. The bar is uniform, unpredictable indices from a secure source, every time.