Almost every "generate a password in the browser" snippet you'll find looks like this:
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
const bytes = crypto.getRandomValues(new Uint8Array(length));
let password = "";
for (const b of bytes) password += charset[b % charset.length];
It uses crypto.getRandomValues, so it feels safe. It isn't quite. The % on the last line introduces modulo bias, and the result is a generator whose output distribution is measurably uneven.
I'll walk through that bias, the fix, and then a second, subtler one that I have not fixed in my own generator yet — because it turns out to be the more interesting problem.
Why the modulo is wrong
A Uint8Array byte is a uniform value in [0, 255] — 256 possible values. The charset above has 70 characters. 256 divided by 70 is 3 remainder 46.
That means characters at index 0–45 can be produced by four different byte values, while characters at index 46–69 can only be produced by three. The first 46 characters of the charset are roughly 33% more likely than the rest.
You will never notice this by looking at generated passwords. It doesn't make them crackable in practice either — the entropy loss is a fraction of a bit per character. But it does mean the generator isn't doing what it claims, and "close enough" is a bad standard for a security primitive. An attacker who knows the bias can order a brute-force search to try likelier characters first.
Rejection sampling
The fix is to throw away the values that cause the imbalance. Compute the largest multiple of the charset size that fits in the random range, and discard anything at or above it:
function randomInt(maxExclusive) {
if (!Number.isInteger(maxExclusive) || maxExclusive <= 0 || maxExclusive > 2 ** 32) {
throw new RangeError("maxExclusive must be an integer in (0, 2^32]");
}
// Largest multiple of maxExclusive that fits in [0, 2^32)
const limit = Math.floor(2 ** 32 / maxExclusive) * maxExclusive;
const buf = new Uint32Array(1);
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit);
return x % maxExclusive;
}
Now every index is equally likely. The loop looks unbounded but isn't a practical concern: with a 32-bit source, the rejection probability for any realistic charset is far below one in ten million per draw, so it terminates on the first iteration essentially always.
Building a password on top of it:
function generatePassword(length, charset) {
let out = "";
for (let i = 0; i < length; i++) {
out += charset[randomInt(charset.length)];
}
return out;
}
That part is easy, and most decent generators get it right.
The harder problem: "must contain at least one of each"
Now add the rule every signup form insists on: at least one uppercase, one digit, one symbol. There are three ways people implement it.
Approach A — post-hoc substitution. Generate, then overwrite a random position with a digit if none is present. This is the bad one. It constrains the output in a way your entropy formula knows nothing about, and the position of the substituted character isn't uniform either.
Approach B — guarantee, then shuffle. Draw one character from each required set, fill the rest from the combined pool, then Fisher–Yates the array. This is what a lot of generators do, mine included. It's clearly better than A. It is still not uniform.
Here's why. Take length 4, pool = digits + letters. Consider two outcomes:
-
5,7,9,a— three digits, one letter -
5,7,a,b— two of each
Under uniform sampling from the pool (conditioned on satisfying the policy), both have 4! = 24 orderings and are equally likely. Under approach B they are not: the guaranteed digit can be any of three in the first case but only two in the second, and the guaranteed letter has one choice versus two. Counting generation paths gives 6 versus 8. Balanced class mixes come out about a third more often than lopsided ones.
Approach C — rejection at the password level. Generate a uniform password, test the policy, discard and regenerate on failure:
function generateWithPolicy(length, charset, satisfies, maxAttempts = 1000) {
for (let i = 0; i < maxAttempts; i++) {
const candidate = generatePassword(length, charset);
if (satisfies(candidate)) return candidate;
}
throw new Error("Policy unsatisfiable for this length and charset");
}
This is uniform over the set of policy-satisfying passwords, which is the thing you actually want. The cost is retries — and for short lengths with many required classes, the retry count is not negligible. At length 8 with four required classes it's fine; at length 6 with four classes you start rejecting a meaningful fraction.
Why this matters for the entropy you display
If you use approach B and then show L · log2(|pool|) bits, that number is an upper bound, not the actual entropy of your generator. The distribution isn't flat, so the real figure is lower.
By how much? For realistic lengths, a fraction of a bit — nobody's password is getting cracked over this. But if the entire pitch of your tool is "here is a number you can trust," shipping an upper bound labelled as the value is the wrong trade. I'd rather state the number honestly or fix the generator.
For context on what these numbers mean at all: entropy here is a property of the generation process, assuming the attacker knows your charset and length. It is not a property of any individual password, and it is not the same thing as the "time to crack" figures strength meters like to show — those depend entirely on a guess rate you assume.
Passphrases
Same helper, different pool. The EFF short wordlist has 1296 words, so each word contributes log2(1296) ≈ 10.3 bits; the long list has 7776 words and gives log2(7776) ≈ 12.9.
function generatePassphrase(wordCount, wordlist, separator = "-") {
return Array.from({ length: wordCount }, () => wordlist[randomInt(wordlist.length)])
.join(separator);
}
Six words from the short list is about 62 bits; from the long list, about 77. The short list trades roughly 2.6 bits per word for shorter, more distinct, easier-to-type words. Which is right depends on whether your users retype these by hand — and unlike the charset case, there's no policy constraint here, so the distribution really is uniform.
Bonus: checking a password against breaches without sending it
If you want to warn users that a password already appears in known breaches, you don't have to transmit it. Have I Been Pwned's range API uses k-anonymity: you SHA-1 the password locally and send only the first five hex characters of the hash. The API returns every suffix sharing that prefix — typically several hundred — and you match locally.
async function timesPwned(password) {
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(password));
const hash = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const body = await res.text();
for (const line of body.split("\n")) {
const [candidate, count] = line.trim().split(":");
if (candidate === suffix) return parseInt(count, 10);
}
return 0;
}
The server learns which of ~1 million prefix buckets you asked about, and nothing else. SHA-1 is fine here — it's an index into a public dataset, not a password hash.
Threat model matters: this is for checking a password a user already has. Never run it on one you just generated — a fresh 90-bit string will never be in the dataset, so the call tells you nothing and only adds a network request.
Where I've landed
None of this needs a backend. crypto.getRandomValues, crypto.subtle, and one fetch to a k-anonymity endpoint cover the whole thing, which is what lets a generator honestly claim that nothing leaves the device.
I build PassWizard on these primitives: rejection sampling for every index, Fisher–Yates for the shuffle, EFF passphrases, HIBP via the range API. And, as of writing, approach B for character-class policies — which is exactly the compromise I just spent three paragraphs picking apart. Writing this up is what made me look at it properly.
So, two questions I'd like other people's take on:
- Is approach C worth it in practice, or is the retry cost at short lengths a good enough reason to accept B's small skew? Is there a construction I'm missing that's uniform without retries?
- Should a generator display entropy bits at all, given users read them as crack times? Bits are honest and meaningless to most people; crack times are legible and rest on assumptions you can't defend. I keep going back and forth.
Top comments (0)