Password Strength Checker
Abstract
Section titled “Abstract”A password strength checker is more nuanced than it looks. Most tutorials count character classes and call it done — “has uppercase, has digit, has symbol — great password!” — which classifies Password1! as strong even though it appears in every leaked-credentials dump. In this project you will build the rule-based checker first, then layer on entropy estimation, a common-password blacklist, a leak-corpus check against Have I Been Pwned, and a final score modeled on zxcvbn.
You will leave understanding:
- Why character-class rules are necessary but not sufficient.
- How to compute password entropy.
- How
zxcvbnactually scores passwords. - How to query Have I Been Pwned safely using k-anonymity.
- How to give actionable feedback, not just a “weak/strong” verdict.
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above.
- A code editor or IDE.
- Comfort with regex (
remodule). - (Optional) Internet access for the HIBP check.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create folder
password-strength-checker. - Inside, create
passwordstrengthchecker.py.
Write the code
Section titled “Write the code”Password Strength Checker
pch.viewSource"""Password strength: the rules everyone writes, then the ones that matter.
The rule-based checker below is the version every tutorial ships, and it is
the weakest part of the file. `Password123@` passes all five rules and is
still a terrible password. What actually separates a strong password from a
weak one is how many guesses it survives, which is what the entropy estimate
and the breach lookup are for.
python passwordstrengthchecker.py # scores the sample list
python passwordstrengthchecker.py --online # also queries Have I Been Pwned
python passwordstrengthchecker.py --test # unit tests, no network
"""
import hashlib
import math
import re
import string
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
COMMON_FILE = HERE / "top-passwords.txt"
def rule_based(password: str) -> list[str]:
"""Every rule the classic checker enforces, reported all at once.
The usual version uses `elif`, so it names one problem per run and the
user fixes them one weary round-trip at a time. Collecting the whole list
costs nothing and is the single biggest usability win available here.
"""
feedback = []
if len(password) < 8:
feedback.append("Use at least 8 characters.")
if not re.search(r"[a-z]", password):
feedback.append("Add a lowercase letter.")
if not re.search(r"[A-Z]", password):
feedback.append("Add an uppercase letter.")
if not re.search(r"[0-9]", password):
feedback.append("Add a digit.")
if not re.search(r"[^A-Za-z0-9]", password):
feedback.append("Add a symbol.")
return feedback
def strength(password: str) -> tuple[str, list[str]]:
"""The verdict the rules alone can give: Strong, or a list of complaints."""
issues = rule_based(password)
return ("Strong" if not issues else "Weak"), issues
def entropy_bits(password: str) -> float:
"""Guessing cost, assuming the attacker knows only which pools were used.
This is an upper bound and a generous one: it treats every character as an
independent random draw. A password built out of a dictionary word scores
far higher here than it deserves, which is exactly why the blacklist and
the breach lookup exist.
"""
pool = 0
if any(c.islower() for c in password):
pool += 26
if any(c.isupper() for c in password):
pool += 26
if any(c.isdigit() for c in password):
pool += 10
if any(c in string.punctuation for c in password):
pool += len(string.punctuation)
return len(password) * math.log2(max(pool, 1))
def has_runs(p: str) -> bool:
"""Repeats and keyboard walks -- the patterns entropy cannot see."""
return bool(re.search(r"(.)\1\1", p)) or bool(
re.search(r"012|123|234|abc|qwe", p.lower()))
def load_common() -> set[str]:
"""The blacklist, or an empty set if the file was never downloaded."""
if not COMMON_FILE.exists():
return set()
return {line.strip().lower()
for line in COMMON_FILE.read_text(encoding="utf-8").splitlines()
if line.strip()}
COMMON = load_common()
def is_common(password: str) -> bool:
return password.lower() in COMMON
def hibp_count(password: str) -> int:
"""How many breaches this password appears in, without sending it anywhere.
Only the first five characters of the SHA-1 leave this machine. The server
returns every suffix sharing that prefix -- some hundreds of them -- and
the match is made locally, so the service never learns which one was
asked about. That is the k-anonymity trick, and it is the only reason
sending a password to a third party is defensible at all.
"""
import urllib.request
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
url = f"https://api.pwnedpasswords.com/range/{prefix}"
with urllib.request.urlopen(url, timeout=10) as response:
body = response.read().decode("utf-8")
for line in body.splitlines():
found, count = line.split(":")
if found.strip() == suffix:
return int(count)
return 0
LEVELS = ["Very Weak", "Weak", "Fair", "Good", "Strong", "Excellent"]
def full_check(password: str, online: bool = False) -> dict:
"""All five signals, scored together.
`breaches` is None rather than 0 when the lookup did not happen. The
difference matters: 0 means the service was asked and had never seen it,
None means nobody checked, and collapsing the two would hand a password
a point it never earned.
"""
issues = rule_based(password)
bits = entropy_bits(password)
common = is_common(password)
breaches = None
if online:
try:
breaches = hibp_count(password)
except Exception as exc: # offline, blocked, down
print(f" (breach lookup unavailable: {exc})")
score = 0
if len(password) >= 12:
score += 1
if not issues:
score += 1
if bits >= 60:
score += 1
if not common:
score += 1
if breaches == 0:
score += 1
return {
"level": LEVELS[score],
"score": score,
"bits": round(bits, 1),
"issues": issues,
"common": common,
"patterned": has_runs(password),
"breaches": breaches,
}
SAMPLES = [
"Password123",
"Password",
"password123",
"PASSWORD123",
"Password@",
"Password123@",
"correct horse battery staple",
"aaa111AAA!!!",
"7#kQx2!vLm9Zt4Rd",
]
def main(online: bool = False) -> None:
print(f"{'password':32} {'verdict':11} {'bits':>6} notes")
print("-" * 78)
for password in SAMPLES:
result = full_check(password, online=online)
notes = []
if result["issues"]:
notes.append(f"{len(result['issues'])} rule(s) failed")
if result["common"]:
notes.append("on the common list")
if result["patterned"]:
notes.append("repeats or a keyboard walk")
if result["breaches"]:
notes.append(f"seen in {result['breaches']:,} breaches")
print(f"{password:32} {result['level']:11} {result['bits']:6.1f} "
f"{'; '.join(notes) or 'nothing flagged'}")
if not online:
print("\nBreach lookup skipped. Re-run with --online to include it.")
if not COMMON:
print(f"No blacklist at {COMMON_FILE.name}, so the common-password "
f"check passed everything by default.")
if __name__ == "__main__":
if "--test" in sys.argv:
import unittest
class TestChecker(unittest.TestCase):
def test_rules_report_everything_at_once(self):
self.assertEqual(len(rule_based("abc")), 4)
def test_strong_password_has_no_issues(self):
self.assertEqual(strength("Password123@")[0], "Strong")
def test_entropy_grows_with_length(self):
self.assertLess(entropy_bits("Ab1!"), entropy_bits("Ab1!Ab1!"))
def test_runs_detected(self):
self.assertTrue(has_runs("aaa123"))
self.assertFalse(has_runs("7#kQx2!vLm9Zt4Rd"))
def test_unchecked_breaches_stay_none(self):
self.assertIsNone(full_check("Password123@")["breaches"])
unittest.main(argv=sys.argv[:1], exit=False)
else:
main(online="--online" in sys.argv) Run it
Section titled “Run it”C:\Users\Your Name\password-strength-checker> python passwordstrengthchecker.py
Password: Password123!
Strong (rule-based)What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.1 s and prints:
password verdict bits notes
------------------------------------------------------------------------------
Password123 Fair 65.5 1 rule(s) failed; repeats or a keyboard walk
Password Weak 45.6 2 rule(s) failed
password123 Weak 56.9 2 rule(s) failed; repeats or a keyboard walk
PASSWORD123 Weak 56.9 2 rule(s) failed; repeats or a keyboard walk
Password@ Weak 57.5 1 rule(s) failed
Password123@ Strong 78.7 repeats or a keyboard walk
correct horse battery staple Good 131.6 2 rule(s) failed
aaa111AAA!!! Strong 78.7 repeats or a keyboard walk
7#kQx2!vLm9Zt4Rd Strong 104.9 nothing flagged
Breach lookup skipped. Re-run with --online to include it.
No blacklist at top-passwords.txt, so the common-password check passed everything by default.How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD
RUN(["python passwordstrengthchecker.py"])
password_strength_checker("password_strength_checker")
RUN --> password_strength_checker
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. The rule-based checker
Section titled “1. The rule-based checker”import re
def rule_based(password: str) -> list[str]:
feedback = []
if len(password) < 8:
feedback.append("Use at least 8 characters.")
if not re.search(r"[a-z]", password):
feedback.append("Add a lowercase letter.")
if not re.search(r"[A-Z]", password):
feedback.append("Add an uppercase letter.")
if not re.search(r"[0-9]", password):
feedback.append("Add a digit.")
if not re.search(r"[^A-Za-z0-9]", password):
feedback.append("Add a symbol.")
return feedbackA list of issues is more useful than a single boolean — the user can fix multiple problems in one revision.
2. Strong vs. weak verdict
Section titled “2. Strong vs. weak verdict”def strength(password: str) -> tuple[str, list[str]]:
issues = rule_based(password)
return ("Strong" if not issues else "Weak"), issues3. Run it
Section titled “3. Run it”for p in ["Password", "password123", "PASSWORD123",
"Password@", "Password123@"]:
verdict, issues = strength(p)
print(f"{p}: {verdict}")
for i in issues: print(f" - {i}")Why Rules Are Not Enough
Section titled “Why Rules Are Not Enough”Password1! satisfies every rule above. So does Qwerty12$. They are also among the most common leaked passwords on Earth. Real strength assessment needs two more inputs:
- Common-password lists — reject anything in the top-N (try
rockyou.txt, ~14 million). - Entropy — measure how much information the password actually carries.
This is not a rhetorical point; the shipped file measures it. Running it over its sample list produces two rows that should end the argument:
Password123@ Strong 78.7 repeats or a keyboard walk
correct horse battery staple Good 131.6 2 rule(s) failedaaa111AAA!!! scores identically to Password123@ — both Strong, both
78.7 bits — because the rules count which character classes appear and
nothing else. Meanwhile the four-word passphrase, at 131.6 bits, is by the
same tool’s own entropy estimate 2^52.9 times harder to guess, and it is
marked down to Good for the crime of containing no capital letter and no
digit.
A checker that ranks the weaker password higher is not a strict checker. It is a wrong one. The rules survive because they are easy to implement and easy to put in a policy document, not because they identify strong passwords.
Entropy Estimation
Section titled “Entropy Estimation”import math, string
def entropy_bits(password: str) -> float:
pool = 0
if any(c.islower() for c in password): pool += 26
if any(c.isupper() for c in password): pool += 26
if any(c.isdigit() for c in password): pool += 10
if any(c in string.punctuation for c in password): pool += len(string.punctuation)
return len(password) * math.log2(max(pool, 1))Rough rubric:
- < 28 bits — very weak (crackable in seconds).
- 28 – 35 — weak (offline crack in minutes).
- 36 – 59 — reasonable for low-value accounts.
- 60 – 127 — strong.
- ≥ 128 — paranoid / master-vault-grade.
A 16-character mix of all four classes lands around 105 bits — well into “strong” territory.
Common-Password Blacklist
Section titled “Common-Password Blacklist”The simplest meaningful upgrade. Get a list (e.g. SecLists’ 10-million-password-list-top-1000000.txt) and:
from pathlib import Path
COMMON = set(Path("top-passwords.txt").read_text().splitlines())
def is_common(password: str) -> bool:
return password.lower() in COMMONSet lookup is O(1). One million entries fits in ~30 MB of RAM — fine for a desktop tool.
Have I Been Pwned (k-Anonymity)
Section titled “Have I Been Pwned (k-Anonymity)”HIBP lets you check a password against billions of leaked entries without sending the password itself:
- SHA-1 your password.
- Send only the first 5 hex characters of the hash.
- Server returns all hashes starting with that prefix (about 500 per query).
- You check locally whether the full hash is in that list.
import hashlib, requests
def hibp_count(password: str) -> int:
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
r = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=10)
r.raise_for_status()
for line in r.text.splitlines():
s, count = line.split(":")
if s == suffix:
return int(count)
return 0A non-zero count means the password appears in known breaches — reject it immediately. Even if it scores high on rules and entropy, attackers will try it first.
Putting It All Together
Section titled “Putting It All Together”def full_check(password: str):
issues = rule_based(password)
bits = entropy_bits(password)
common = is_common(password)
try:
breaches = hibp_count(password)
except Exception:
breaches = None
score = 0
if len(password) >= 12: score += 1
if not issues: score += 1
if bits >= 60: score += 1
if not common: score += 1
if breaches == 0: score += 1
levels = ["Very Weak", "Weak", "Fair", "Good", "Strong", "Excellent"]
return {
"level": levels[score],
"bits": round(bits, 1),
"issues": issues,
"common": common,
"breaches": breaches,
}Now full_check("Password1!") says “Weak — found in 1.6 million breaches” instead of “Strong — has all four character classes.”
Why You Should Recommend zxcvbn
Section titled “Why You Should Recommend zxcvbn”For a real product use zxcvbn-python (a Python port of Dropbox’s library):
pip install zxcvbnfrom zxcvbn import zxcvbn
r = zxcvbn("Tr0ub4dor&3")
print(r["score"], r["feedback"]["warning"], r["crack_times_display"])It models real attacker behavior — dictionary attacks, l33t substitutions, keyboard patterns, dates, repeats — and gives a 0-4 score plus an estimated time to crack at four attack scenarios. Your rule-based code is educational; zxcvbn is production.
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
Password1! rated strong | Only checked character classes | Add common-password + breach check |
| HIBP request fails | No internet | Make the breach check optional, surface as “unknown” |
| Sending password over the network | Naive API design | Use the k-anonymity range API |
| Locale-specific punctuation missed | Used a fixed regex | Use re.search(r"[^A-Za-z0-9]", password) for “any non-alphanumeric” |
| Logs/print contain the password | Debugging left in | Never log passwords; never even print echoes |
| Comparing in plain text | Custom logic | Always hash before comparing/storing |
Variations to Try
Section titled “Variations to Try”1. Pattern detection
Section titled “1. Pattern detection”Reject keyboard runs (qwerty, asdf), repeated characters (aaa), and sequences (12345).
import re
def has_runs(p):
return bool(re.search(r"(.)\1\1", p)) or bool(re.search(r"012|123|234|abc|qwe", p.lower()))2. Dates and years
Section titled “2. Dates and years”A 4-digit substring matching 19xx or 20xx is almost certainly a birth year — flag it.
3. Personal-info check
Section titled “3. Personal-info check”Accept the user’s name, email, and birth date; reject any password containing those.
4. GUI version
Section titled “4. GUI version”A Tkinter form with a colored progress bar that shifts red → yellow → green as the user types. See Calculator GUI for the layout pattern.
5. Web frontend
Section titled “5. Web frontend”Flask + a textarea (see Basic Web Server). Never ship the password to the server unless you absolutely must; do the check in the browser with JS.
6. Estimated crack time
Section titled “6. Estimated crack time”Multiply entropy by hashes-per-second assumptions:
- Online (1 000 guesses/s): how long to brute force?
- Offline GPU (1 trillion guesses/s): how long?
7. Suggest a fix
Section titled “7. Suggest a fix”If the password is too short, append a random word. If a class is missing, insert one. If common, regenerate entirely.
8. Pair with the generator
Section titled “8. Pair with the generator”Cross-reference with Random Password Generator. Show the strength of every generated password.
9. Account-wide policy
Section titled “9. Account-wide policy”Read a config file specifying minimums (length, classes, entropy, max breach count) and enforce them.
10. CLI tool
Section titled “10. CLI tool”strength check "Tr0ub4dor&3"
strength suggest --length 16 --no-ambigSecurity Best Practices the Tool Should Reinforce
Section titled “Security Best Practices the Tool Should Reinforce”- Length over complexity. A 16-character random passphrase beats
Aa1!Aa1!. - Unique per service. Even a strong password is weak if reused across breached sites.
- Never trust client-side checks alone. A web app must enforce policy on the server too.
- Treat the input as a secret. No logging, no caching, no analytics tracking the actual value.
- Hash + salt server-side. Never store passwords plaintext.
Real-World Applications
Section titled “Real-World Applications”- Signup forms with real-time strength feedback.
- Password-change flows that reject reuse.
- IT admin tools that audit a company’s password hashes.
- Onboarding wizards that teach users why
Password1!is weak. - Self-service password reset with leak-corpus protection.
Educational Value
Section titled “Educational Value”- Regex — pattern matching across character classes.
- Entropy math — turning intuition into a number.
- k-anonymity — privacy-preserving lookup design (a wonderful idea worth knowing).
- API integration — calling a real third-party service safely.
- UX of security — actionable feedback vs. red-X verdicts.
Next Steps
Section titled “Next Steps”- Implement the common-password blacklist above.
- Add HIBP k-anonymity check.
- Switch to
zxcvbnfor production scoring. - Add pattern detection (sequences, runs, dates).
- Build a GUI with a colored progress bar.
- Pair with Random Password Generator for a complete password tooling pair.
Conclusion
Section titled “Conclusion”You built a rule-based password checker, learned why the rules alone are dangerously incomplete, and added entropy + blacklist + breach-corpus checks that turn the tool into something you would actually trust. Real password security is a deceptively deep field, and even a small checker can do a real job — or quietly mislead users. Full source on GitHub. Find more security-focused projects on Python Central Hub.
Pitfalls
Section titled “Pitfalls”- The
elifchain reports one problem at a time.passwordfails on the missing number, and the missing uppercase and missing symbol are never mentioned — the user fixes one thing, resubmits, and is told about the next. Collecting every failure into a list and reporting them together is a small change and a large difference. - The special-character class is
[_@$]— three characters. A password ending in!or#is rejected as having no symbol, which is both wrong and the kind of rule that pushes people towardPassword1@instead of something long. - Complexity rules do not measure strength.
Password123@passes every check here and is one of the most guessed passwords in every breach corpus. Length and unpredictability are what matter; a rule set that accepts a dictionary word with predictable decoration is measuring compliance, not security. - Nothing is returned. The function prints and returns
None, so no caller can act on the result. A checker that cannot be used inside anifis a script, not a function.
- Seven test passwords, run on this machine: five rejected, two accepted.
- The
elifchain stops at the first failure, so a password with three problems is reported as having one. [_@$]is a three-character symbol class, so!and#do not count.Password123@satisfies every rule and is still a weak password — the rules measure shape, not unpredictability.- The function prints instead of returning, so its verdict cannot be used by anything.
Try it yourself
Section titled “Try it yourself”-
Given the password `password`, the checker reports only the missing number. Why does it not mention the missing uppercase letter and symbol as well?
pch.quizShowAnswer
B — The branches are `elif`, so the first failing condition ends the chain and no later check ever runs
-
`Password123@` passes every rule in this checker. Is it a strong password?
pch.quizShowAnswer
B — No. It is a dictionary word with predictable decoration, and appears in breach corpora. The rules measure shape, which is not the same as being hard to guess
-
The symbol check is a three-character class: underscore, at-sign and dollar. What does that reject?
pch.quizShowAnswer
B — Every symbol except underscore, at-sign and dollar — so a password ending in ! or # is told it has no special character
-
The function prints its verdict rather than returning it. Why does that matter?
pch.quizShowAnswer
B — Because nothing can act on it: no caller can write `if check(password):`, and no test can assert on the outcome without capturing stdout
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading