Most password rules lie to users. "At least 8 characters, one uppercase, one number, one special character" — that regex check produces passwords like Password1! which scores 100% on every consumer-facing strength meter but cracks in minutes with a dictionary attack. Entropy-based validation gives you a number that actually correlates with cracking resistance.
What Is Password Entropy and Why It Matters
Entropy, measured in bits, tells you how many guesses an attacker needs in the worst case. The formula:
H = L × log₂(N)
Where L is password length and N is the size of the character pool in use.
A password using only lowercase letters (N = 26) at 8 characters gives H = 8 × log₂(26) ≈ 37.6 bits. That means at most 2^37.6 ≈ 200 billion possible values — sounds large until you consider a GPU cluster doing 10 billion guesses per second. With full ASCII printable characters (N = 95) at 8 characters, you get ≈ 52.6 bits. Double the length to 16 characters: ≈ 105 bits. Practically unbreakable with current hardware.
The core insight: length beats complexity rules every time.
Detecting Character Pools
Before calculating entropy, determine which character sets the password draws from. More pools used means a larger N.
import math
import re
CHARSETS = {
"lowercase": (re.compile(r'[a-z]'), 26),
"uppercase": (re.compile(r'[A-Z]'), 26),
"digits": (re.compile(r'[0-9]'), 10),
"special": (re.compile(r'[^a-zA-Z0-9]'), 32),
}
def detect_charset_size(password: str) -> int:
size = 0
for _, (pattern, pool_size) in CHARSETS.items():
if pattern.search(password):
size += pool_size
return size if size > 0 else 1
def calculate_entropy(password: str) -> float:
n = detect_charset_size(password)
length = len(password)
if length == 0:
return 0.0
return length * math.log2(n)
This returns theoretical maximum entropy — what you would get if the characters were drawn randomly from those pools. Real passwords are not random, so raw entropy overestimates strength. You need additional heuristic checks on top.
Building the Full Validator
Entropy alone misses practical weaknesses: common dictionary words, keyboard walk patterns (qwerty, asdf), repetitive characters. A production validator layers several signals.
from typing import NamedTuple
COMMON_PASSWORDS = {
"password", "123456", "password1", "qwerty", "abc123",
"letmein", "iloveyou", "admin", "welcome", "monkey",
"dragon", "master", "sunshine", "princess", "football",
"shadow", "superman", "michael", "jessica", "password123",
}
KEYBOARD_WALKS = ["qwerty", "asdf", "zxcv", "qazwsx", "1234", "abcd"]
class PasswordReport(NamedTuple):
entropy: float
strength: str # "weak" | "fair" | "strong" | "very_strong"
score: int # 0-100
issues: list[str]
suggestions: list[str]
def validate_password(password: str) -> PasswordReport:
issues: list[str] = []
suggestions: list[str] = []
penalty = 0
entropy = calculate_entropy(password)
if len(password) < 8:
issues.append("Too short (minimum 8 characters)")
suggestions.append("Use at least 12 characters for meaningful security")
penalty += 40
if password.lower() in COMMON_PASSWORDS:
issues.append("This password appears in breach databases")
suggestions.append("Avoid dictionary words and known patterns")
penalty += 60
for walk in KEYBOARD_WALKS:
if walk in password.lower():
issues.append(f"Contains keyboard walk pattern '{walk}'")
suggestions.append("Avoid sequential keyboard patterns")
penalty += 20
break
if len(set(password)) / max(len(password), 1) < 0.5:
issues.append("Too many repeated characters")
suggestions.append("Use a wider variety of characters")
penalty += 15
if entropy < 28:
base_score = 10
elif entropy < 36:
base_score = 30
elif entropy < 50:
base_score = 55
elif entropy < 70:
base_score = 75
elif entropy < 90:
base_score = 90
else:
base_score = 100
score = max(0, min(100, base_score - penalty))
if score < 25:
strength = "weak"
if not suggestions:
suggestions.append("Consider a passphrase: 4 random words beat most complex short passwords")
elif score < 50:
strength = "fair"
elif score < 75:
strength = "strong"
else:
strength = "very_strong"
return PasswordReport(
entropy=round(entropy, 2),
strength=strength,
score=score,
issues=issues,
suggestions=suggestions,
)
A few representative outputs:
-
"password"→ 37.6 bits entropy, hit in common list → score 0, weak -
"P@ssw0rd1!"→ ~65 bits, no blocklist match → score 75, strong -
"correct-horse-battery-staple"→ 131+ bits, no issues → score 100, very_strong
That last one illustrates why passphrases win. The entropy formula rewards length above all else.
Exposing This as an API Endpoint
If you are validating passwords at registration, here is a minimal FastAPI endpoint:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class PasswordCheckRequest(BaseModel):
password: str
class PasswordCheckResponse(BaseModel):
entropy_bits: float
strength: str
score: int
issues: list[str]
suggestions: list[str]
@app.post("/validate-password", response_model=PasswordCheckResponse)
def check_password(req: PasswordCheckRequest) -> PasswordCheckResponse:
report = validate_password(req.password)
return PasswordCheckResponse(
entropy_bits=report.entropy,
strength=report.strength,
score=report.score,
issues=report.issues,
suggestions=report.suggestions,
)
Two hard rules for this endpoint: never log the password itself (only the score and metadata), and always run this check server-side even if you also run it in the browser. Client-side validation is UX; server-side validation is security.
For production, complement the local blocklist with a Have I Been Pwned API lookup using the k-anonymity endpoint: send the first 5 hex characters of the SHA-1 hash, then check if the full hash appears in the returned list. That covers breached passwords your static list does not know about.
Passphrases: Counting Words, Not Characters
The character-based entropy formula underestimates passphrase strength. A 4-word Diceware passphrase drawn from a 7776-word list has log2(7776^4) ≈ 51.7 bits regardless of character count. Detect phrases and score them separately:
def passphrase_entropy(phrase: str) -> float:
words = phrase.strip().split()
if len(words) < 3:
return 0.0
# Conservative estimate: ~7776-word Diceware list
return len(words) * math.log2(7776)
def smart_entropy(password: str) -> float:
words = password.split()
if len(words) >= 3:
return passphrase_entropy(password)
return calculate_entropy(password)
"correct horse battery staple" yields 4 × 12.9 ≈ 51.7 bits — comparable to a strong random character password but far easier to remember and type correctly. If your UI currently bans spaces in passwords, remove that restriction.
The Takeaway
Entropy calculation gives you an honest measure of password strength — one tied to actual attack cost rather than checkbox compliance. The implementation above handles:
- Theoretical entropy from character pool detection
- Common password blocklist with penalty scoring
- Keyboard walk pattern detection
- Repetition penalty
- Passphrase detection with word-level entropy
- A clean 0–100 score with human-readable suggestions
If you are hardening an authentication system more broadly, check the security hardening checklists we publish — free PDF and Excel versions covering auth, API security, and container hardening.
The most impactful change most teams can make today: stop enforcing arbitrary complexity rules and set a minimum entropy threshold of 50 bits instead. Users get more flexibility, attackers face higher costs, and you stop training people to write Password1!.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)