Every authentication system β regardless of how sophisticated β starts with a password. Even in a passkey-first world, every system has a password recovery path. And every password recovery path is only as strong as the passwords users create.
This guide explains the mathematics of password strength from first principles, covers real-world cracking hardware benchmarks, and gives you Python code to measure and enforce password strength programmatically.
Entropy measures the uncertainty in a password β how many guesses an attacker needs, on average, to find it. The formula is straightforward:
Where:
Each bit of entropy doubles the number of possible passwords. A password with 40 bits of entropy has 2β΄β° possible combinations. At 80 bits, it's 2βΈβ° β or roughly 1.2 Γ 10Β²β΄ possibilities.
| Character Set | N (pool size) | Example |
|---|---|---|
| Digits only | 10 | 123456 |
| Lowercase | 26 | qwerty |
| Lowercase + digits | 36 | abc123 |
| Mixed case | 52 | PassWord |
| Mixed case + digits | 62 | Pass123 |
| All printable ASCII | 94 | P@ssw0rd! |
| Full Unicode (BMP) | 65,536 | πππ‘οΈ |
| Password | Length | Set | Entropy | Crack Time (SHA-1, 100 GH/s) |
|---|---|---|---|---|
| 123456 | 6 | 10 | ~20 bits | < 1 millisecond |
| password | 8 | 26 | ~38 bits | < 3 milliseconds |
| Pass123 | 7 | 62 | ~42 bits | < 10 milliseconds |
| P@ssw0rd! | 9 | 94 | ~59 bits | ~180 seconds |
| correct-horse-battery | 20 | 26 | ~94 bits | ~200,000 years |
| CSPRNG 16-char | 16 | 94 | ~105 bits | ~40 million years |
| CSPRNG 20-char | 20 | 94 | ~131 bits | ~170 billion years |
There's a persistent myth that requiring mixed case, digits, and symbols is the most important password rule. The math says otherwise. Every additional character of length multiplies the search space by N (the pool size). Adding a character class adds at most ~5.5 bits per character position (94/26 β 3.6x, or logβ(3.6) β 1.85 extra bits per position).
Adding 1 character of length (94-char set): +6.55 bits
Adding symbols to lowercase-only: +1.85 bits per position
A 12-character lowercase password has 12 Γ logβ(26) = 56 bits. A 10-character full-ASCII password has 10 Γ logβ(94) = 65 bits. The 12-character lowercase password with fewer character classes is stronger because it's longer.
Password cracking hardware advances every generation. Here are conservative benchmarks for a single RTX 5090 GPU (released 2025) running hashcat:
| Algorithm | Hash Rate (single RTX 5090) | 8-GPU Rig |
|---|---|---|
| MD5 | 180 GH/s | 1.4 TH/s |
| SHA-1 | 100 GH/s | 800 GH/s |
| SHA-256 | 30 GH/s | 240 GH/s |
| NTLM | 200 GH/s | 1.6 TH/s |
| bcrypt (cost=5) | 80 KH/s | 640 KH/s |
| bcrypt (cost=10) | 2.5 KH/s | 20 KH/s |
| bcrypt (cost=12) | 640 H/s | 5.1 KH/s |
| argon2id (1,64,4) | 400 H/s | 3.2 KH/s |
| scrypt (N=16384, r=8, p=1) | 150 KH/s | 1.2 MH/s |
Notice the difference between cost=5 and cost=12 for bcrypt: a factor of 128x. And notice how bcrypt cost=12 at 5.1 KH/s on an 8-GPU rig means cracking a 105-bit (16-char CSPRNG) password takes longer than the current age of the universe.
Here's a Python implementation that calculates entropy and provides a strength rating:
#!/usr/bin/env python3 """Password strength evaluator β calculates entropy bits and cracking costs.""" import math import hashlib import struct from typing import Tuple, Dict CHARSETS = { "lower": set("abcdefghijklmnopqrstuvwxyz"), "upper": set("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), "digit": set("0123456789"), "symbol": set("!@#$%^&*()-_=+[]{}|;':\",./<>?`~"), } def calculate_entropy(password: str) -> float: """Calculate password entropy in bits.""" # Determine effective character pool size pool_size = 0 for name, charset in CHARSETS.items(): if any(c in charset for c in password): pool_size += len(charset) # Detect if password has characters outside known sets known_chars = set().union(*CHARSETS.values()) unknown = set(password) - known_chars if unknown: pool_size += 94 # Assume full ASCII printable for unknown chars # Guard against zero pool if pool_size == 0: pool_size = 94 return len(password) * math.log2(pool_size) def crack_time_estimate(entropy: float, hash_rate: float = 100e9) -> Dict: """Estimate time to crack at given hash rate (default: SHA-1 on RTX 5090).""" combinations = 2 ** entropy seconds = combinations / (2 * hash_rate) # Average case = half the search space intervals = [ ("nanoseconds", 1e-9), ("microseconds", 1e-6), ("milliseconds", 1e-3), ("seconds", 1), ("minutes", 60), ("hours", 3600), ("days", 86400), ("years", 31536000), ] for name, unit in intervals: if seconds < unit * 1000: value = seconds / unit return { "value": round(value, 1), "unit": name, "seconds": seconds, } return {"value": round(seconds / 31536000, 1), "unit": "years", "seconds": seconds} def strength_rating(entropy: float) -> Tuple[str, str]: """Return strength rating and color code for a given entropy value.""" if entropy < 30: return ("Very Weak", "#ef4444") elif entropy < 50: return ("Weak", "#f97316") elif entropy < 70: return ("Moderate", "#eab308") elif entropy < 90: return ("Strong", "#22c55e") else: return ("Very Strong", "#22d3ee") def evaluate_password(password: str) -> Dict: """Full password evaluation.""" entropy = calculate_entropy(password) rating, color = strength_rating(entropy) cracking = crack_time_estimate(entropy) return { "password_length": len(password), "entropy_bits": round(entropy, 1), "strength_rating": rating, "crack_time": f"{cracking['value']} {cracking['unit']}", "recommendation": ( "Length β₯ 16 characters from full ASCII set, " "generated using crypto.getRandomValues()." ) if entropy < 80 else "Strong", } # Example usage if __name__ == "__main__": result = evaluate_password("5rX!kP9mQ@2nB&vW") print(f"Entropy: {result['entropy_bits']} bits") print(f"Rating: {result['strength_rating']}") print(f"Crack time (SHA-1, single GPU): {result['crack_time']}")
Entropy calculations assume uniform random selection from the character set. This is only true if you use a cryptographically secure pseudorandom number generator (CSPRNG). Common non-CSPRNG sources:
| Source | Type | Security | Recoverable? |
|---|---|---|---|
JavaScript Math.random() |
Xorshift128+ | β Not secure | Yes β 624 consecutive outputs recover the full state |
Python random |
Mersenne Twister (MT19937) | β Not secure | Yes β 624 outputs recover 19,937-bit state |
Java java.util.Random |
LCG (48-bit) | β Not secure | Yes β 2 outputs recover state |
PHP rand() |
LCG (various) | β Not secure | Yes |
C rand() |
LCG | β Not secure | Yes |
JavaScript crypto.getRandomValues() |
OS CSPRNG (NIST SP 800-90A) | β Secure | No β true entropy sources |
Python secrets |
OS CSPRNG | β Secure | No |
/dev/urandom |
OS CSPRNG (Linux) | β Secure | No |
The Mersenne Twister (used by Python's random module and many other languages) has a 19,937-bit internal state. An attacker who observes 624 consecutive outputs can reconstruct the full state and predict ALL future outputs β including any passwords generated with that RNG instance. This is why all password generation must use CSPRNG sources.
crypto.getRandomValues() backed by the operating system's CSPRNG, consistent with NIST SP 800-90A. Every password is generated client-side β never transmitted over the network.Password strength isn't mysterious β it's measurable. Entropy in bits, cracking time at known hash rates, and the quality of the random number source all yield objective numbers. A password with 105 bits of entropy from a CSPRNG, stored with bcrypt cost 12, is computationally immune to any offline attack foreseeable in the next decade.
Length is the single most important factor. Every additional character adds ~6.55 bits of entropy with a full ASCII set. A 20-character CSPRNG password delivers ~131 bits β beyond any feasible cracking budget for the foreseeable future.
Start with a strong, CSPRNG-generated password. Store it with memory-hard hashing. Check it against known breaches. And enable MFA as the safety net. That's your first line of defence β measurable, verifiable, and effective.