A Password Strength Checker With Entropy Math and Crack-Time Estimates
Strength = length × log₂(charset size) − penalties for common patterns. Crack time = 2^entropy / guesses_per_second. It's not zxcvbn, but it gets the basics right and shows users why their password is weak (bullet list of issues) instead of just a red bar with no explanation.
Most password strength meters are vibes-based: a bar that goes red/yellow/green based on opaque rules. Users learn to game the meter without actually making their passwords stronger. A meter that explains its reasoning — "you need a symbol", "this is in the top 500 common passwords", "you have 4 repeated characters" — is more educational.
🔗 Live demo: https://sen.ltd/portfolio/password-strength/
📦 GitHub: https://github.com/sen-ltd/password-strength
Features:
- Strength bar with 5 levels
- Entropy in bits
- Crack-time estimate (seconds to centuries)
- Specific improvement suggestions
- 500+ common password blocklist
- Secure password generator (crypto.getRandomValues)
- Passphrase mode (4-6 random words from 200+ wordlist)
- Character class toggles, ambiguous character exclusion
- Japanese / English UI
- Zero dependencies, 67 tests
Entropy from character pool
export function calculateEntropy(password) {
let poolSize = 0;
if (/[a-z]/.test(password)) poolSize += 26;
if (/[A-Z]/.test(password)) poolSize += 26;
if (/\d/.test(password)) poolSize += 10;
if (/[^a-zA-Z0-9]/.test(password)) poolSize += 32;
if (poolSize === 0) return 0;
return password.length * Math.log2(poolSize);
}
The formula assumes each character is chosen uniformly from the pool. It's an upper bound — real passwords are much less random, but it gives a consistent way to compare lengths × character classes.
Common pool sizes:
- Lowercase only: 26 chars → 4.7 bits per char
- + Uppercase: 52 chars → 5.7 bits per char
- + Digits: 62 chars → 5.95 bits per char
- + Symbols: 94 chars → 6.55 bits per char
So a 12-character lowercase password has ~56 bits; adding uppercase, digits, and symbols brings it to ~79 bits. Adding two more characters to the all-classes version reaches ~92 bits, diminishing returns compared to just making it longer.
Crack time
export function crackTimeEstimate(entropy, guessesPerSec = 1e10) {
const secs = Math.pow(2, entropy) / guessesPerSec;
if (secs < 1) return 'instant';
if (secs < 60) return `${Math.round(secs)} seconds`;
if (secs < 3600) return `${Math.round(secs / 60)} minutes`;
if (secs < 86400) return `${Math.round(secs / 3600)} hours`;
if (secs < 31536000) return `${Math.round(secs / 86400)} days`;
if (secs < 31536000 * 1000) return `${Math.round(secs / 31536000)} years`;
return 'centuries';
}
10 billion guesses per second is a reasonable assumption for offline attacks against a properly-hashed password database (bcrypt/Argon2 slows this dramatically, but sha256 / md5 is in this range on modern GPUs).
At 10^10 guesses/sec:
- 40 bits → 110 seconds
- 50 bits → 31 hours
- 60 bits → 3.6 years
- 70 bits → 3700 years
The NIST threshold for "acceptable against offline attack" is about 64 bits. Most tools I've seen recommend 80+ for meaningful margin.
The common password blocklist
No amount of entropy math matters if your password is Password123! — it's in every cracking dictionary. A 500-entry blocklist catches the obvious cases:
export const COMMON_PASSWORDS = new Set([
'password', '123456', 'qwerty', 'abc123', 'letmein', 'admin',
'password1', 'Password1', 'Password123', 'P@ssw0rd',
// ... 500 total
]);
export function isCommonPassword(password) {
return COMMON_PASSWORDS.has(password.toLowerCase());
}
When the blocklist hits, the score is capped at 0 regardless of entropy. A long "common" password (correcthorsebatterystaple — actually famously used in XKCD) scores well on entropy but low on novelty.
Sequential and repeated patterns
export function hasSequential(str) {
const s = str.toLowerCase();
for (let i = 0; i < s.length - 2; i++) {
const c0 = s.charCodeAt(i);
const c1 = s.charCodeAt(i + 1);
const c2 = s.charCodeAt(i + 2);
if (c1 === c0 + 1 && c2 === c1 + 1) return true; // abc, 123
if (c1 === c0 - 1 && c2 === c1 - 1) return true; // zyx, 321
}
return false;
}
export function hasRepeated(str) {
return /(.)\1\1/.test(str); // aaa, 111
}
These patterns reduce effective entropy significantly — qwertyuiop has theoretical ~47 bits but dictionary attacks crack it in milliseconds. Detecting and flagging them feeds into the bullet-list feedback.
Crypto-secure generation
export function randomInt(max) {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % max;
}
Math.random() is NOT suitable for password generation — it's seeded from the current time and is predictable. crypto.getRandomValues draws from the OS entropy pool. In Node 18+ and all modern browsers, it's available on globalThis.crypto.
Passphrases
The XKCD approach: 4 random words = ~52 bits if your wordlist has ~8000 entries:
export function generatePassphrase(wordCount, separator, wordlist) {
const words = [];
for (let i = 0; i < wordCount; i++) {
words.push(wordlist[randomInt(wordlist.length)]);
}
return words.join(separator);
}
My wordlist has 200+ entries. Smaller than EFF's 7776-word list (~12.9 bits/word) but still produces memorable passwords at ~7.6 bits/word. At 4 words that's ~31 bits — too weak. At 6 words, ~46 bits — borderline. Users should prefer the character-based generator for anything sensitive, but passphrases have their place for typed credentials.
Series
This is entry #77 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/password-strength
- 🌐 Live: https://sen.ltd/portfolio/password-strength/
- 🏢 Company: https://sen.ltd/

Top comments (0)