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
- Python 3.6 or above.
- A code editor or IDE.
- Comfort with regex (
remodule). - (Optional) Internet access for the HIBP check.
Getting Started
Create the project
- Create folder
password-strength-checker. - Inside, create
passwordstrengthchecker.py.
Write the code
Password Strength Checker
Source"""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
C:\Users\Your Name\password-strength-checker> python passwordstrengthchecker.py
Password: Password123!
Strong (rule-based)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
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
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
def strength(password: str) -> tuple[str, list[str]]:
issues = rule_based(password)
return ("Strong" if not issues else "Weak"), issues3. 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
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
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
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)
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
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
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
| 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
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
A 4-digit substring matching 19xx or 20xx is almost certainly a birth year — flag it.
3. Personal-info check
Accept the user's name, email, and birth date; reject any password containing those.
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
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
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
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
Cross-reference with Random Password Generator. Show the strength of every generated password.
9. Account-wide policy
Read a config file specifying minimums (length, classes, entropy, max breach count) and enforce them.
10. CLI tool
strength check "Tr0ub4dor&3"
strength suggest --length 16 --no-ambigSecurity 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
- 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
- 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
- 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
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
- 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.
Recap
- 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
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Given the password `password`, the checker reports only the missing number. Why does it not mention the missing uppercase letter and symbol as well?
Show answer
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?
Show answer
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?
Show answer
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?
Show answer
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