The passphrase mode in my password generator was weak. It produced passphrases with at most about 26 bits of entropy, and the page described the tool as “100% secure”. That claim was wrong.
A security review of my site’s tools found the problem on 23 Oct 2026. I fixed it the same day. It is my tool and it is a security tool, so I am writing down what was wrong and what I changed.
Why the passphrase generator was weak: a ten-word list and Math.random()
Version 1.0 of the Password Generator Tool plugin had three problems in passphrase mode. This is the word list and the call, from my server backup of 1.0:
// Sample word list (expand for production).
const words = ['apple', 'banana', 'cherry', 'date', 'elder', 'fig', 'grape', 'honey', 'ice', 'jelly'];
password = generatePassphrase(5, useNumbers, useSymbols);And this is the function:
function generatePassphrase(wordCount, addNumbers, addSymbols) {
let passphrase = '';
for (let i = 0; i < wordCount; i++) {
passphrase += words[Math.floor(Math.random() * words.length)] + ' ';
}
if (addNumbers) passphrase += Math.floor(Math.random() * 100);
if (addSymbols) passphrase += '!@#$%^&*'[Math.floor(Math.random() * 8)];
return passphrase.trim();
}The word list was a placeholder. The comment says “expand for production”, and it went to production with ten words in it. A passphrase is only as strong as the list it is drawn from.
The label promised something the code did not do. The interface said “3-10 words”, but there was no word-count input. The code always called generatePassphrase(5, …), so every passphrase had five words.
It used Math.random(). That covered the words, the number and the symbol.
Passphrase entropy: how 26 bits came out of five words
Entropy in bits is the base-2 logarithm of the number of equally likely outputs. Each extra bit doubles the number of guesses an attacker needs.
- Five words picked from ten is 10 × 10 × 10 × 10 × 10 = 100,000 combinations. That is about 16.6 bits.
- A number from 0 to 99 is 100 options, about 6.6 bits.
- One symbol from a set of eight is 3 bits.
Multiply the options together and you get 100,000 × 100 × 8 = 80 million combinations, which is about 26.3 bits. The review described that as guessable offline in seconds if the password is stored with a fast hash. A slow hash buys time, but not enough to make 26 bits safe.
Notice that the list size caps the strength. Even a perfect random generator choosing from ten words gives you very little, which is why the word list mattered more than anything else here.
Math.random() vs crypto.getRandomValues() for secrets
Math.random() is not a cryptographic generator. MDN’s documentation says not to use it for anything related to security. For secrets the platform gives you crypto.getRandomValues(), which is built for the job. I should have used it for every random choice in the tool, not only in character mode.
How I found it
The review rated the passphrase problem medium severity. Its findings were checked against the code before anything changed, and the “100% secure” claim failed the same check. Nothing in the code backed it up.
The fix
Version 1.1 changed three things.
- The ten-word list became the EFF large wordlist: 7,776 words, licensed CC BY 3.0 US. Each word is worth about 12.9 bits, because log2 of 7,776 is 12.925.
- The passphrase is now six words, and the label says “(6 words)” instead of “(3-10 words)”.
- The words, the number and the symbol all come from
crypto.getRandomValues, with rejection sampling.
This is the current code, as it runs in version 1.2:
// EFF large wordlist (7,776 words, ~12.9 bits each), loaded by eff-wordlist.js.
const words = window.pgtWords || [];
// Uniform random integer in [0, max) from the browser's cryptographic generator (rejection sampling, no modulo bias).
function randomInt(max) {
const limit = Math.floor(0x100000000 / max) * max;
const buf = new Uint32Array(1);
do {
window.crypto.getRandomValues(buf);
} while (buf[0] >= limit);
return buf[0] % max;
}function generatePassphrase(wordCount, addNumbers, addSymbols) {
if (words.length < 7776) return 'Error: word list not loaded';
let passphrase = '';
for (let i = 0; i < wordCount; i++) {
passphrase += words[randomInt(words.length)] + ' ';
}
if (addNumbers) passphrase += randomInt(100);
if (addSymbols) passphrase += '!@#$%^&*'[randomInt(8)];
return passphrase.trim();
}Modulo bias and rejection sampling
getRandomValues gives a 32-bit number. There are 232 possible values, and only list sizes that are a power of two divide that evenly. 7,776 is not one. If you take the number modulo the list size, the low results come up slightly more often than the high ones. That is modulo bias. Rejection sampling avoids it. randomInt works out the largest multiple of max no bigger than 232, throws away any number at or above it and draws again. What is left maps evenly onto the list.
Why the guard. The original failure was a bad word list. The current code checks that the list has all 7,776 entries before it makes a passphrase. If the list is missing or short, the tool shows an error instead of a weaker passphrase. The check counts the words; it does not prove they are the right ones, so the live list has also been checked against EFF’s published file, word for word.
The new strength. Six words at about 12.9 bits each is about 77.5 bits. The number adds about 6.6 and the symbol adds 3, so a passphrase with both comes to about 87 bits. That is up from about 26. Both are ticked by default. With them off, six words alone is still about 77.5 bits.
Testing showed that all 7,776 words load, that 12,000 draws looked uniform (a sanity check, not a proof), that character mode was unchanged and that the console showed no errors. A server backup came first, and the live files were checked against my local copy by sha256. I deployed the same day and the live site was tested again in Chrome.
One honest limit: this runs as JavaScript on a web page, so you are trusting the code my site sends you over HTTPS on each visit, and the other scripts on the page, such as analytics. For your most important secrets, a password manager’s own generator keeps the whole thing off the web.
The next day: character mode
Character mode already used crypto.getRandomValues, but it picked characters with array[i] % charset.length. That is the same modulo bias, but with a 32-bit number and fewer than 100 characters it is tiny, far too small to help an attacker. I fixed it anyway, because one correct function used everywhere is easier to trust than two. On 24 Oct 2026 I released version 1.2, which moves character mode to the same randomInt. The release also enforces a length of 8 to 30 and includes some accessibility fixes.
On 26 Oct 2026 I also removed the “100% secure” copy. The page now says only what I can show from the code: “Passwords are generated in your browser, and nothing is sent to a server.”
What I took from it
- Placeholder data can reach production. A comment saying “expand for production” is a to-do, and it needs a check before release.
- A label is a promise. “3-10 words” promised a choice the code did not offer. If the interface says it, the code has to do it.
- “Secure” needs evidence. If I cannot show why a claim is true, I do not put it on the page.
- Use the platform’s cryptographic generator for anything secret, and use it for every choice, not only some of them.
- Calculate entropy, do not guess it. Count the options and take the logarithm. Five words from a ten-word list and a number sounds fine until you do the sum.
Checking claims against evidence is also the core of how I clean up a hacked WordPress site. You can try the fixed passphrase mode in my password generator.
I used AI to help draft this post from my own idea and notes. I verified the information and edited the text myself.



