I ran a targeted dictionary attack against my own test account using a list of 10 million common passwords from the RockYou2021 dataset. My old βsecureβ password β an 8-character mix of letters and numbers β cracked in under four seconds. The password I had been using for a decade was in a public breach database. Understanding how this works changes how you think about both your own credentials and what you build for your users.
Generate strong passwords locally β nothing leaves your browser.
Cryptographically random passwords using the Web Crypto API. No server, no logging.
Open Password Generator βPassword Entropy: The Math
Entropy measures how unpredictable a password is, expressed in bits. A password with N bits of entropy has 2N possible values. The formula is:
// Entropy formula: H = L Γ log2(C)
// H = entropy in bits
// L = password length (characters)
// C = character set size (number of possible characters per position)
// Character set sizes:
const lowercase = 26; // a-z
const uppercase = 26; // A-Z
const digits = 10; // 0-9
const symbols = 32; // Common keyboard symbols
const alphanumeric = 62; // a-zA-Z0-9
const full = 94; // All printable ASCII
// Examples:
// 8-char lowercase: 8 Γ log2(26) = 37.6 bits
// 8-char alphanumeric: 8 Γ log2(62) = 47.6 bits
// 12-char alphanumeric: 12 Γ log2(62) = 71.5 bits
// 12-char full ASCII: 12 Γ log2(94) = 78.9 bits
// 20-char lowercase: 20 Γ log2(26) = 94.0 bits (very strong)
// 4-word passphrase (EFF): 4 Γ log2(7776) = 51.7 bits (wordlist)
function calculateEntropy(length: number, charsetSize: number): number {
return length * Math.log2(charsetSize);
}Crack Times vs. Entropy
Crack times depend heavily on whether the attacker has the raw hash and what hashing algorithm was used. Here are realistic numbers for a bcrypt cost-12 hash versus unsalted MD5, at RTX 4090 speeds:
| Password | Entropy | MD5 (164B/sec) | bcrypt cost 12 (1,800/sec) |
|---|---|---|---|
| 8-char lowercase | 37.6 bits | Instant | ~2 weeks |
| 8-char alphanumeric | 47.6 bits | Under 1 min | 200 years |
| 12-char alphanumeric | 71.5 bits | Days | Millions of years |
| 12-char with symbols | 78.9 bits | Months | Heat death of universe |
| 4-word EFF passphrase | 51.7 bits | HoursβDays | 1,000+ years |
The pattern is clear: the hashing algorithm matters more than the password at most entropy levels. An 8-character alphanumeric password is unbreakable behind bcrypt, but trivial behind MD5. This is why the hashing choice is a developer responsibility, not a user responsibility.
What NIST SP 800-63B Actually Recommends
TheNIST SP 800-63B Digital Identity Guidelines(updated 2024) contain several recommendations that contradict common practice:
- Minimum length of 8 characters for user-generated passwords. NIST recommends allowing up to 64 characters.
- No forced periodic rotation unless there is evidence of compromise. Forced rotation increases the frequency of weak, predictable passwords.
- No complexity rules (such as βmust contain uppercase, number, and symbolβ). These rules lead to predictable patterns like
Password1!. - Check against known-compromised password lists. Compare new passwords against breach databases (e.g., HaveIBeenPwned's API) and reject matches.
- Allow paste in password fields. Blocking paste forces users to type passwords manually, discouraging the use of long generated passwords.
Implementing Correct Password Storage
import bcrypt from 'bcryptjs';
// Store a new password
async function storePassword(plaintext: string): Promise<string> {
// Cost factor 12 = ~300ms per hash on modern hardware
// Adjust based on your server speed: aim for 100β500ms
const COST = 12;
return bcrypt.hash(plaintext, COST);
}
// Verify at login
async function verifyPassword(
plaintext: string,
storedHash: string
): Promise<boolean> {
// bcryptjs.compare is timing-safe β prevents timing attacks
return bcrypt.compare(plaintext, storedHash);
}
// Check against HaveIBeenPwned (k-anonymity model β no full password sent)
async function isBreached(password: string): Promise<boolean> {
const hash = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(password)
);
const hashHex = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase();
// Send only first 5 characters of hash β server returns matching suffixes
const prefix = hashHex.slice(0, 5);
const suffix = hashHex.slice(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await response.text();
return text.split('
').some(line => line.startsWith(suffix));
}Generating Cryptographically Secure Passwords
// Use the Web Crypto API β cryptographically secure, available in all browsers
function generatePassword(
length: number = 16,
includeSymbols: boolean = true
): string {
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
const digits = '0123456789';
const symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?';
const charset = uppercase + lowercase + digits + (includeSymbols ? symbols : '');
const array = new Uint8Array(length);
// getRandomValues uses the OS CSPRNG β not Math.random()
crypto.getRandomValues(array);
return Array.from(array)
.map(byte => charset[byte % charset.length])
.join('');
}
// Example: 20-character password with symbols
const password = generatePassword(20, true);
// β "kR$9vN!mX2@pL#8qW&5j" (different every time)Common Implementation Mistakes
Timing Attacks on Password Comparison
A naive string comparison like storedHash === inputHash leaks timing information β it returns faster when the first character does not match. An attacker can measure response times to deduce hash characters one by one. Always use a timing-safe comparison function. bcrypt.compareis timing-safe. Node's crypto.timingSafeEqual is the raw primitive.
Storing Password Hints
Password hints are a security risk. If your hint is βmy dog's nameβ and the attacker has access to your social media, they can narrow the search space dramatically. NIST explicitly recommends not implementing password hints.
Truncating Long Passwords
Some systems silently truncate passwords above a maximum length. A user who sets a 64-character password finds their actual password is only 16 characters long β but they do not know this. Their long password gives false confidence while providing less security than they intended. If you must have a maximum (for bcrypt: 72-byte effective limit), tell the user.
The Practical Summary
For developers: use bcrypt with cost 12, check new passwords against HaveIBeenPwned, allow paste, remove complexity requirements, stop forcing rotation. These five changes bring your implementation in line with current NIST guidance and make your users more secure, not less.
For personal use: use a password manager. Every site gets a different 20-character random password. This makes credential stuffing impossible regardless of whether any individual site is breached.
For the hashing algorithm details β specifically why MD5 and SHA-256 are wrong for passwords even though they are correct for other use cases β read our MD5 safety guide.
Generate a cryptographically secure password locally.
Uses the Web Crypto API. Nothing is sent to a server. Nothing is logged.
Open Password Generator β