I run a pile of password generators on this site, and until September 17th, 2026 the strongest thing about any of them was the word, "strong" in the title. Not really, they were strong; just not as strong as they could be. The main one promises secure, random passwords. The Flask one hands you a 64-character key to sign your session cookies with. The Unicode one said it used a "cryptographically strong PRNG."
I fixed all of them, because none of those promises held up. Short version: anything you generated here before September 17, 2026 is still random, it just isn't as random as I said it was. Rotate it when you get a chance. That's what I'd do.
The setup
Seven generators ran on PCG32: the strong random password generator, the passphrase generator, the MySQL password generator, PostgreSQL, Discord in English and Spanish, and the Flask secret key generator.
PCG32 is a nice little random number generator. It's fast and it passes statistical tests. It is not a cryptographic generator. Its whole state is a 64-bit number plus a stream selector, and every character it hands you is a pure function of those two values.
I knew that. My defense was the seed, which came from crypto.getRandomValues. Here's the old Flask page, minus the DOM lookups:
function firstLoad() {
var buf = new BigUint64Array(1);
crypto.getRandomValues(buf);
seedInput.value = buf[0];
streamInput.value = Date.now() % 51513;
lengthInput.value = 64;
initPCG32();
}
function initPCG32() {
var seed = parseInt(seedInput.value);
var stream = parseInt(streamInput.value);
pcg32 = new PCG32(seed, stream);
gen();
}
A real 64-bit random seed. Good start. Then I wrote it into a text box so visitors could see it and edit it, and read it back out with parseInt.
Where the bits went
parseInt returns a JavaScript Number. A Number is an IEEE 754 double, and a double has 53 bits of mantissa. It holds every integer up to 2^53 exactly. Above that it starts skipping. Between 2^63 and 2^64, where half of all random 64-bit values land, neighboring doubles are 2,048 apart.
So the seeds 13835058055282163712 and 13835058055282164735 are different numbers that both come out of parseInt as the same double. Same double, same PCG32 state, same 64-character key. Run them both through the old code and you get identical output.
Counting it out:
- Half of all seeds land in [2^63, 2^64). That range holds only 2^52 distinct doubles.
- The next quarter land in [2^62, 2^63), another 2^52 doubles, each half as likely.
- The single most likely seed has probability 2^-53. That's 53 bits of min-entropy. Shannon entropy works out to about 54.
The stream was the clock. Date.now() % 51513 has 51,513 possible values, about 15.7 bits, and only if an attacker has no idea when you loaded the page. A timestamp isn't a secret, so I don't count it as randomness.
Now compare that to what the Flask page implied. A 64-character key drawn from 64 symbols should hold:
64 × log2(64) = 64 × 6 = 384 bits
It held about 53 bits of real randomness plus a clock reading. Length didn't help either. Every character came out of the same seeded generator, so a 256-character key had the same ceiling as a 16-character one.
The off-by-one that banned a letter
Five of those pages (main, MySQL, PostgreSQL, and both Discord pages) picked characters like this:
function getNextLetter() {
var idx = pcg32.random() % (letters.length - 1);
return letters[idx];
}
letters.length - 1 is the index of the last character, not the number of characters. The last one could never come up. On all five pages the last character was a capital P. Two million picks through the old code produce 80 of the 81 characters, and zero P's.
My commit message called this bias, which is generous. The textbook modulo bias here is tiny: 2^32 mod 80 is 16, so 16 characters were favored by about one part in 53.7 million. Losing P entirely costs a 34-character password about 0.6 bits (215.6 down to 214.9). Neither one gets anybody hacked. It's just the kind of bug you don't want in a tool with "strong" in the name.
The Unicode generator was worse, and luckier
The Unicode random password generator used a bundled Fortuna generator instead of PCG32, and the only entropy that generator ever received was this (deminified; sha512 returns a hex string):
entropyFunction = function () {
return sha512("" + new Date().getTime());
};
A hash of the time in milliseconds. Hashing doesn't add entropy. A whole day is 86,400,000 milliseconds, about 2^26.4. A whole year is about 2^34.9. Those are small numbers.
That should have been game over. It wasn't, and not because of anything I did right. Each random number came out of CryptoJS AES with a string key, and CryptoJS treats a string key as a passphrase. It derives the real key and IV using a fresh 8-byte salt, and that salt comes from Math.random().
Run the old bundle twice with the same frozen timestamp and you get two different passwords. Pin Math.random to fixed values as well, and the same timestamp gives the same password every time. So Math.random was the whole entropy source on that page, the SHA-512 and AES around it didn't help at all.
In V8, Math.random is xorshift128+. It's statistically decent and makes no promise about resisting prediction. I don't know of a practical way to rebuild a password from it with only a timestamp to go on. That still isn't "cryptographically strong," and I'm not going to argue that accidental salt is a security control.
How bad was it, really
Honest answer: bad on paper, hard to exploit in practice.
Account passwords you type into a website are the least exposed. Nobody is making 2^53 login attempts.
A Flask SECRET_KEY is the interesting case, because it can be attacked offline. Grab one signed session cookie from the app, then for each candidate seed build the key and check the signature. Assume a billion guesses a second. That's an assumption for the math, not a benchmark:
| What the attacker knows | Guesses | Time at 10^9/s |
|---|---|---|
| Exact page-load millisecond, half the seeds | 2^52 | about 52 days (about a day on 50 machines) |
| Nothing about timing | 2^53 × 51,513 ≈ 2^68.7 | about 14,700 years |
| What the page promised | 2^384 | more guesses than atoms in the observable universe |
Knowing the time to the second still leaves 1,000 stream values, and they'd also have to know your key came from my page at all. So this was never a weekend project for a bored attacker. The page still promised 384 bits and delivered nowhere near that.
The Unicode page rested entirely on Math.random. I'd treat those passwords as the weakest of the bunch.
The fix
Every generator now loads secure-random.js, which calls crypto.getRandomValues for every single pick. No seed, no stream, no text box. The core is rejection sampling:
// Uniform integer in [0, max), max <= 2^32.
function int(max) {
var limit = 4294967296 - (4294967296 % max);
var buf = new Uint32Array(1);
for (;;) {
cryptoObj.getRandomValues(buf);
if (buf[0] < limit) return buf[0] % max;
}
}
limit is the largest multiple of max that doesn't go past 2^32. Anything at or above it gets thrown away and redrawn, so every index is exactly equally likely. For a 64-character alphabet it never rejects anything, since 2^32 divides evenly by 64. For 81 characters it rejects 49 values out of 4.29 billion. You'll never notice the loop.
Characters come from alphabet[int(alphabet.length)]. The whole length this time. The PCG32, jsspg and Fortuna bundles are deleted.
Real numbers now: a 64-character Flask key is 384 bits. A 32-character password from 81 symbols is about 203 bits. A 26-character Unicode password from 759 symbols is about 249 bits.
The passphrase page had a different seeding path. It took both the seed and the stream from crypto.getRandomValues and squeezed each one under 2^53. That's roughly 106 bits of generator setup behind a five-word phrase that can only hold about 85, so it wasn't capped the way the others were. Its wordlist did have 2,613 duplicate entries, which made a few thousand words two or three times as likely as the rest. That's fixed too.
If you generated something here before September 17, 2026
Rotate it. None of this is urgent, I just like the habit. Ordered by what I'd get to first:
- Unicode passwords. Change them.
- Flask secret keys. Replace the key. Every existing session gets logged out. That's the price.
- MySQL and PostgreSQL passwords. Rotate them, especially on anything reachable from the internet.
- Account passwords from the main or Discord generators. Low risk, cheap to change. Put the new one in your password manager.
- Passphrases. Lowest priority. Still cheap.
What I'm taking from this
On the PCG32 pages, the CSPRNG was never the problem. The plumbing around it was. A 64-bit value went through a text box and parseInt and came out as a double, and nothing complained.
The seed and stream fields were the bug. I added them so the pages would feel transparent and tweakable. They're also why the seed was a string in the first place.
Output that looks random proves nothing. Every password those pages produced looked great. A statistical test would have passed PCG32 all day. I found it by reading my own code with the assumption that it's already broken. That's a hard mindset to get into. There's no test that'll do that for you.
That assumption is most of what I'm writing about in Assume It Will Fail, my field guide to AI security for founders and builders. You can join the waitlist there. In the meantime, rotate that Flask key. No rush.
-Sethers