Methodology and test results
Everything a ProtemKey tool tells you is computed by two small JavaScript files, keys.js (generators and entropy) and strength.js (the checker). Every weekly rebuild runs those exact files in Node against the tests below and refuses to publish if one fails. Last run: 2026-10-31, Node v20.19.2.
1. Where the randomness comes from
All randomness comes from crypto.getRandomValues, the Web Crypto API's cryptographically secure generator, which browsers seed from the operating system. Math.random is never used. Values are drawn as 32-bit unsigned integers, 16,384 at a time.
To pick one of n things (a word, a character, a digit), the generator needs a whole number from 0 to n−1 with every value equally likely. Taking a random 32-bit number modulo n is slightly biased unless n divides 232, so the generator uses rejection sampling:
limit = 2^32 − (2^32 mod n) // the largest multiple of n that fits
repeat: x = next random 32-bit value
until x < limit
return x mod n
Every value below limit maps to each result exactly limit / n times, so the result is exactly uniform. The cost is an occasional redraw. In the worst case (n just over 231) about half of all draws are rejected: in this run, 1,000,000 calls with n = 231+1 used 2,000,644 draws, 2.0006 per call against 2.0000 expected. For 7,776 words the rejection chance is about 1 in 1,677,722. Modulo bias and rejection sampling explains this step by step.
Rules such as “at least one of each kind” or “avoid obvious PINs” are applied the same way: a candidate that breaks the rule is thrown away and a fresh one drawn, which keeps every allowed result equally likely.
2. Uniformity tests
Each test runs the generator functions visitors use, counts how often each outcome appears, and compares the counts with a perfectly even spread using Pearson's chi-square test. The p-value is the chance a perfectly fair generator would look at least this uneven; a test fails below p = 0.001. A fair generator fails such a test about once in a thousand runs by chance, so a failure triggers one full re-run before the build is stopped. This run needed 1 attempt.
| Test | What was sampled | Samples | Outcomes | Fewest / most per outcome | χ² | df | p | |
|---|---|---|---|---|---|---|---|---|
| Passphrase words (EFF list) | passphrase(), 6 words per phrase, 1,296,000 phrases | 7,776,000 | 7,776 | 882 / 1,109 | 7756.8 | 7775 | 0.556 | pass |
| Password characters (94 printable) | password(), length 100, 100,000 passwords | 10,000,000 | 94 | 105,460 / 107,053 | 89.7 | 93 | 0.578 | pass |
| Whole short passwords (3 digits) | password(), every one of the 1,000 possible strings counted | 2,000,000 | 1,000 | 1,842 / 2,134 | 982.6 | 999 | 0.638 | pass |
| “At least one of each” stays uniform | password(), length 2, lowercase + digits, both required: 520 distinct strings seen of 520 allowed | 1,040,000 | 520 | 1,852 / 2,112 | 517.9 | 519 | 0.506 | pass |
| PIN digit pairs | pin(), 6 digits, 1,000,000 PINs, each split into three 2-digit pairs | 3,000,000 | 100 | 29,661 / 30,353 | 79.5 | 99 | 0.925 | pass |
| randomBelow(7) | the uniform-integer primitive, n = 7 | 7,000,000 | 7 | 997,913 / 1,002,126 | 10.9 | 6 | 0.091 | pass |
| Naive byte % 94 (deliberately biased control) | one random byte, then % 94: the shortcut rejection sampling avoids | 9,400,000 | 94 | 72,602 / 110,709 | 254337 | 93 | < 10⁻³⁰⁰ | bias detected |
The last row is a control. A byte has 256 values, and 256 = 2 × 94 + 68, so the naive method makes the first 68 characters 1.5 times as likely as the other 26. The same test catches that instantly, which shows it has the power to find real bias, not just that it passes. The full output of this run is at /validation.json.
In a browser. On 2026-10-26 the same two main tests ran inside headless Chrome/124.0.6367.78, loading this site's keys.js and calling the browser's own crypto.getRandomValues: passphrase words over 7,776,000 samples, χ² = 7891.2 on 7775 df, p = 0.175; password characters over 10,000,000 samples, χ² = 72.0 on 93 df, p = 0.947. Both pass.
3. Entropy formulas, checked by hand
The strength shown by each generator is computed from its settings, never estimated from its output: it is log2 of the number of equally likely results. The cases below were worked out by hand as exact integer expressions (in the “count” column), evaluated by an independent Python script that shares no code with keys.js, and compared with what keys.js reports. They must agree to within 10−9 bits.
| Setting | Hand calculation | Count | Bits (hand) | Bits (keys.js) | |
|---|---|---|---|---|---|
| Passphrase: 6 words, lowercase, hyphens (the default) | Six independent picks from 7,776 words: 7,776^6 = 2.21 × 10^23 phrases. The separator is fixed, so it adds nothing. | 221073… (24 digits) | 77.5489 | 77.5489 | match |
| Passphrase: 4 words, one word in CAPITALS, plus a digit and a symbol | 7,776^4 word choices × 4 places for the capitals × (10 digits × 4 words) × (8 symbols × 4 words). | 187195… (20 digits) | 64.0212 | 64.0212 | match |
| Passphrase: 5 words, each randomly capitalized or not | 7,776^5 × 2 choices of capitalization per word. | 909769… (21 digits) | 69.6241 | 69.6241 | match |
| Password: 20 characters, lower + upper + digits + 13 easy symbols (the default) | 75 possible characters, 20 independent picks: 75^20. | 317121… (38 digits) | 124.5764 | 124.5764 | match |
| Password: 16 characters from all 94 printable ASCII | 26 + 26 + 10 + 32 = 94 characters; 94^16. | 371574… (32 digits) | 104.8734 | 104.8734 | match |
| Password: 12 characters, lowercase + digits, look-alikes removed | Lowercase without l and o leaves 24; digits without 1 and 0 leave 8; 32^12 = 2^60, exactly 60 bits. | 115292… (19 digits) | 60.0000 | 60.0000 | match |
| Password: 3 characters, lowercase + digits, at least one of each | All 36^3 strings, minus the 26^3 with no digit, minus the 10^3 with no letter: 46,656 − 17,576 − 1,000 = 28,080. | 28080 | 14.7773 | 14.7773 | match |
| Password: 8 characters, all four classes (94), at least one of each | Inclusion-exclusion over the four classes (26, 26, 10, 32): subtract strings missing one class, add back those missing two, subtract those missing three; none can miss all four. Missing one class leaves 68, 68, 84 or 62 characters; missing two leaves 42, 58, 36, 58, 36 or 52; missing three leaves 32, 10, 26 or 26. About 2.8 × 10^15 of the 6.1 × 10^15 eight-character strings qualify: the rule costs 1.1 bits. | 280765… (16 digits) | 51.3183 | 51.3183 | match |
| PIN: 4 digits | 10,000 PINs from 0000 to 9999. | 10000 | 13.2877 | 13.2877 | match |
| PIN: 4 digits, obvious PINs avoided | Remove 10 repeats (0000…9999), 7 runs up (0123…6789), 7 runs down (9876…3210), 90 more repeated pairs (abab, not already counted), 128 more years 1900-2029 (1919 and 2020 are already pairs), and 150 further all-digit 4-character entries from the common-password list. | 9608 | 13.2300 | 13.2300 | match |
| PIN: 6 digits | 1,000,000 PINs. | 1000000 | 19.9316 | 19.9316 | match |
| PIN: 6 digits, obvious PINs avoided (the default) | Remove 10 repeats, 5 runs up (012345…456789), 5 runs down, 90 more repeated pairs (ababab), and 89 further all-digit 6-character entries from the common-password list. | 999801 | 19.9313 | 19.9313 | match |
The formulas, for reference: k words from a list of W: k·log2W; one capitalized word of k: +log2k; random capitals: +k; a digit or symbol from a set of s on one of k words: +log2s + log2k; L characters from N: L·log2N; with every one of the chosen kinds required, inclusion-exclusion over the kinds; PINs: log2(10L − excluded).
4. Bands and attack rates
The meter colors a number of bits by these bands. They are judgment calls, tied to the fast offline attack rate: below 30 bits falls in under a few seconds there; 75 bits takes thousands of years on average.
| Band | Bits | Fast offline, 1011/s, at the band's lower edge |
|---|---|---|
| Very weak | below 30 | – |
| Weak | 30 to 45 | less than a second |
| Fair | 45 to 60 | 2.9 minutes |
| Strong | 60 to 75 | 67 days |
| Very strong | 75 and up | 6 thousand years |
| Scenario | Rate (assumed) | Situation |
|---|---|---|
| Online, rate-limited | 100 guesses per hour | a login form that slows down or locks out repeated failures |
| Offline, slow hash | 10,000 guesses per second | a stolen database hashed with a deliberately slow, salted scheme (bcrypt class) |
| Offline, fast hash | 100 billion guesses per second | a stolen database hashed with a fast unsalted hash (MD5, SHA-1 or NTLM class) on a high-end GPU rig |
5. The strength checker and its labelled test set
The checker estimates guessing cost for a password a person typed: it splits the password into the cheapest pieces it can explain (common-list entries, dictionary words, keyboard walks, repeats, sequences, dates) and charges per character for the rest. The full rule table is on the checker page. Its common list is SecLists' “10k most common” (10,001 entries) and its dictionary is the EFF large wordlist (7,776 words).
It was tested on 90 passwords, each labelled with the band a realistic attacker would put it in. The labels were written before the checker was run on them and were not changed afterwards. Result: 71 in the expected band, 87 within one band, 5 rated stronger than labelled and 14 weaker. Rating a password two or more bands too strong is the dangerous mistake, and the build fails on it unless the case is listed as a known limit.
Where it disagrees with the labels
| Password | Label | Checker | Bits | Note |
|---|---|---|---|---|
ilovemycat | very-weak | weak (+1) | 33.8 | a short common phrase |
Ch@rlie2012 | weak | very-weak (-1) | 14.4 | a name, swaps and a year |
Mustang1969! | weak | very-weak (-1) | 17.2 | |
MyDogRex2015! | weak | fair (+1) | 49.8 | |
correcthorse | weak | very-weak (-1) | 22.3 | two dictionary words |
coffee&cake | weak | very-weak (-1) | 25.2 | |
JohnSmith1975 | weak | very-weak (-1) | 25.7 | |
london2012olympics | weak | very-weak (-1) | 27.5 | a city, an event year and a word |
Kx9#mT | weak | very-weak (-1) | 27.2 | 6 random-looking characters (39.3 bits if drawn from all 94) |
h7Gq2x | weak | very-weak (-1) | 25.4 | 6 random-looking letters and digits (35.7 bits if drawn from 62) |
q7#Lm2!x | fair | weak (-1) | 35.5 | 8 random printable characters: 52.4 bits |
Xk3pL9wQ2m | fair | weak (-1) | 42.9 | 10 random letters and digits: 59.5 bits |
Hq8vN2xLp4Rz | strong | fair (-1) | 52.3 | 12 random letters and digits: 71.5 bits |
7rC#vL2q9Wx!pZ4e | very-strong | strong (-1) | 70.4 | 16 random printable: 104.9 bits |
Known limits
Tr0ub4dor&3: labelled weak, rated fair (47.9 bits). the checker's dictionary is only the 7,776 EFF words, so 'troubador' is charged letter by letter.Nov-14-1988: labelled weak, rated weak (33.9 bits). month names are not in the date rules, so 'Nov' is charged as three letters.2580: labelled very-weak, rated very-weak (12.7 bits). keypad walks are not modelled; only the QWERTY keyboard is.correct horse battery staple: labelled very-weak, rated fair (54.6 bits). the checker has no list of famous passphrases; it scores the four words as if chosen at random.My first car was a blue 1998 Civic!: labelled fair, rated very-strong (97.9 bits). the checker charges most words of a sentence as random dictionary picks; real attackers model grammar and personal facts.rT6!vP2@kL9#: labelled very-strong, rated fair (53.3 bits). per-class charging (4.7 bits a letter, 3.3 a digit) under-rates random strings that are mostly digits and letters; the checker cannot know it was generated.PurpleMonkeyDishwasher: labelled fair, rated weak (39.3 bits). the checker has no list of phrases from popular culture.
All 90 labelled cases
| Password | Label | Checker | Bits |
|---|---|---|---|
password | very-weak | very-weak | 1 |
123456 | very-weak | very-weak | 1.6 |
qwerty | very-weak | very-weak | 2.6 |
iloveyou | very-weak | very-weak | 6.7 |
Password1 | very-weak | very-weak | 5.3 |
P@ssw0rd | very-weak | very-weak | 4 |
Passw0rd! | very-weak | very-weak | 8 |
letmein! | very-weak | very-weak | 8.6 |
monkey123 | very-weak | very-weak | 7.3 |
dragon2020 | very-weak | very-weak | 10.1 |
abc123 | very-weak | very-weak | 3.9 |
111111 | very-weak | very-weak | 4.4 |
7777777 | very-weak | very-weak | 6.1 |
qwertyuiop | very-weak | very-weak | 11 |
1qaz2wsx | very-weak | very-weak | 9.4 |
zaq12wsx | very-weak | very-weak | 11.7 |
asdfgh | very-weak | very-weak | 6.6 |
1234qwer | very-weak | very-weak | 10.3 |
Summer2024! | very-weak | very-weak | 19.5 |
Spring2023 | very-weak | very-weak | 18 |
Welcome1 | very-weak | very-weak | 11.6 |
Football! | very-weak | very-weak | 9.5 |
admin123 | very-weak | very-weak | 13.1 |
Admin@123 | very-weak | very-weak | 19.2 |
passw0rd | very-weak | very-weak | 2 |
01011990 | very-weak | very-weak | 14.8 |
12/25/1990 | very-weak | very-weak | 17.6 |
superman | very-weak | very-weak | 4.6 |
trustno1 | very-weak | very-weak | 4.9 |
sunshine1 | very-weak | very-weak | 8.9 |
aaaaaa | very-weak | very-weak | 6.8 |
abcabcabc | very-weak | very-weak | 5.2 |
987654321 | very-weak | very-weak | 5.2 |
princess! | very-weak | very-weak | 11.5 |
Qwerty123! | very-weak | very-weak | 12.2 |
Liverpool1 | very-weak | very-weak | 14 |
baseball7 | very-weak | very-weak | 6.6 |
michael1985 | very-weak | very-weak | 11.2 |
Jennifer01 | very-weak | very-weak | 12 |
drowssap | very-weak | very-weak | 2 |
!QAZ2wsx | very-weak | very-weak | 21.3 |
football4life | very-weak | very-weak | 17.7 |
ilovemycat | very-weak | weak | 33.8 |
q1w2e3r4t5 | very-weak | very-weak | 18.1 |
hello world | very-weak | very-weak | 21.4 |
Ch@rlie2012 | weak | very-weak | 14.4 |
Mustang1969! | weak | very-weak | 17.2 |
MyDogRex2015! | weak | fair | 49.8 |
correcthorse | weak | very-weak | 22.3 |
BlueSky42 | very-weak | very-weak | 22 |
coffee&cake | weak | very-weak | 25.2 |
Tr0ub4dor&3 | weak | fair | 47.9 |
Bananas!! | very-weak | very-weak | 18.8 |
JohnSmith1975 | weak | very-weak | 25.7 |
sweetpea22 | very-weak | very-weak | 17.2 |
zebra-lamp | weak | weak | 36 |
Nov-14-1988 | weak | weak | 33.9 |
london2012olympics | weak | very-weak | 27.5 |
2580 | very-weak | very-weak | 12.7 |
Kx9#mT | weak | very-weak | 27.2 |
h7Gq2x | weak | very-weak | 25.4 |
glacier violin nutmeg | weak | weak | 43.8 |
q7#Lm2!x | fair | weak | 35.5 |
correct horse battery staple | very-weak | fair | 54.6 |
parrot-tiger-cactus-wobble | fair | fair | 49.1 |
Xk3pL9wQ2m | fair | weak | 42.9 |
My first car was a blue 1998 Civic! | fair | very-strong | 97.9 |
Hq8vN2xLp4Rz | strong | fair | 52.3 |
ladle-unbent-sprout-casing-gerbil | strong | strong | 72.3 |
rT6!vP2@kL9# | very-strong | fair | 53.3 |
unwind tamale exile pebble cobweb outlet | very-strong | very-strong | 86.6 |
7rC#vL2q9Wx!pZ4e | very-strong | strong | 70.4 |
Oyster.Banjo.Wiry.Trench.Dimmer.Rebel.Haiku | very-strong | very-strong | 105.9 |
mN4$sQ8&wE2^zR7*tY5! | very-strong | very-strong | 88.8 |
ilovechocolatecake | weak | weak | 34.5 |
PurpleMonkeyDishwasher | fair | weak | 39.3 |
Iloveyou4ever! | very-weak | very-weak | 25.1 |
Pa$$word2024 | very-weak | very-weak | 11.1 |
letmein123456 | very-weak | very-weak | 5.2 |
11223344 | very-weak | very-weak | 10.9 |
abcdefgh | very-weak | very-weak | 5 |
zxcvbnm123 | very-weak | very-weak | 11.4 |
Winter2025! | very-weak | very-weak | 20.8 |
Tigger123 | very-weak | very-weak | 9.7 |
loveyou | very-weak | very-weak | 10.2 |
whatever | very-weak | very-weak | 7 |
starwars | very-weak | very-weak | 5.9 |
batman1 | very-weak | very-weak | 8.6 |
123abc! | very-weak | very-weak | 12.2 |
access14 | very-weak | very-weak | 10.2 |
Against secrets with known entropy
The checker was also run on 200 fresh outputs of each generator setting below, whose true entropy is known exactly. It may under-rate them, since it can't know a string was generated, but it must never rate one more than 15 bits stronger than it really is.
| Generator setting | True bits | Checker: lowest | median | highest | |
|---|---|---|---|---|---|
| Passphrase, 4 words, hyphens | 51.7 | 53.2 | 58.7 | 61.7 | pass |
| Passphrase, 6 words, spaces | 77.5 | 77.1 | 86.3 | 87.8 | pass |
| Passphrase, 5 words, Title Case, no separator | 64.6 | 58.2 | 69.6 | 69.8 | pass |
| Password, 12, letters + digits | 71.5 | 40.4 | 53.6 | 56.4 | pass |
| Password, 16, all 94 printable | 104.9 | 63.3 | 73.2 | 78.0 | pass |
| Password, 20, default settings | 124.6 | 77.7 | 90.2 | 95.7 | pass |
| Password, 10, lowercase only | 47.0 | 35.9 | 47.0 | 47.0 | pass |
Random passphrases come out a few bits high, because the checker charges for the separator and capitals that the generator's figure treats as known settings. Random character strings come out low, because the checker charges each character by its own kind (4.7 bits for a letter) rather than the full set it was drawn from. Both are why the generators show their exact figure and the checker is for passwords people choose.
6. Sources and licenses
- EFF Large Wordlist (Joseph Bonneau, Electronic Frontier Foundation, 2016), eff_large_wordlist.txt. EFF publishes its original material under Creative Commons Attribution (its copyright page names CC BY 4.0). Used unchanged; pinned by SHA-256.
- SecLists
Passwords/Common-Credentials/10k-most-common.txt(Daniel Miessler and contributors), github.com/danielmiessler/SecLists, MIT license. Pinned to commit 913b327 by SHA-256. Used only inside the checker and for the computed statistics in the guides; a few offensive entries are left out wherever entries are displayed. - NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management (August 2025), pages.nist.gov/800-63-4/sp800-63b.html. A work of the US government, quoted and paraphrased.
- Other sources (IETF RFCs, W3C recommendations, OWASP cheat sheets, papers) are cited on the guide that uses them.
- Everything else, including all text, code and illustrations, is original to ProtemKey.
7. What the build checks
- The two downloaded lists match their pinned SHA-256 hashes; the wordlist has 7,776 distinct words, is prefix-free, and its hyphenated words don't split into list words.
- All uniformity tests pass (one re-run allowed), and the biased control is detected.
- Every entropy figure in section 3 matches the hand calculation.
- The checker never rates a labelled password two bands too strong (outside known limits) and never over-rates a generated secret by more than 15 bits.
- Every internal link resolves, and every guide has its required fields.