Skip to content

Password Strength Checker

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 zxcvbn actually scores passwords.
  • How to query Have I Been Pwned safely using k-anonymity.
  • How to give actionable feedback, not just a “weak/strong” verdict.
  • Python 3.6 or above.
  • A code editor or IDE.
  • Comfort with regex (re module).
  • (Optional) Internet access for the HIBP check.
  1. Create folder password-strength-checker.
  2. Inside, create passwordstrengthchecker.py.
Password Strength Checker pch.viewSource
Password Strength Checker
"""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)
command
C:\Users\Your Name\password-strength-checker> python passwordstrengthchecker.py
Password: Password123!
Strong (rule-based)

Running the file exactly as it ships takes 0.1 s and prints:

python passwordstrengthchecker.py
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.

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.

diagram Diagram mermaid
passwordstrengthchecker.py
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 feedback

A list of issues is more useful than a single boolean — the user can fix multiple problems in one revision.

passwordstrengthchecker.py
def strength(password: str) -> tuple[str, list[str]]:
    issues = rule_based(password)
    return ("Strong" if not issues else "Weak"), issues
passwordstrengthchecker.py
for p in ["Password", "password123", "PASSWORD123",
          "Password@", "Password123@"]:
    verdict, issues = strength(p)
    print(f"{p}: {verdict}")
    for i in issues: print(f"  - {i}")

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:

  1. Common-password lists — reject anything in the top-N (try rockyou.txt, ~14 million).
  2. 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:

text
Password123@                     Strong        78.7  repeats or a keyboard walk
correct horse battery staple     Good         131.6  2 rule(s) failed

aaa111AAA!!! 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.py
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.

The simplest meaningful upgrade. Get a list (e.g. SecLists’ 10-million-password-list-top-1000000.txt) and:

blacklist.py
from pathlib import Path
COMMON = set(Path("top-passwords.txt").read_text().splitlines())
 
def is_common(password: str) -> bool:
    return password.lower() in COMMON

Set lookup is O(1). One million entries fits in ~30 MB of RAM — fine for a desktop tool.

HIBP lets you check a password against billions of leaked entries without sending the password itself:

  1. SHA-1 your password.
  2. Send only the first 5 hex characters of the hash.
  3. Server returns all hashes starting with that prefix (about 500 per query).
  4. You check locally whether the full hash is in that list.
hibp.py
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 0

A 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.

full_check.py
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.”

For a real product use zxcvbn-python (a Python port of Dropbox’s library):

install
pip install zxcvbn
zxcvbn_use.py
from 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.

ProblemCauseFix
Password1! rated strongOnly checked character classesAdd common-password + breach check
HIBP request failsNo internetMake the breach check optional, surface as “unknown”
Sending password over the networkNaive API designUse the k-anonymity range API
Locale-specific punctuation missedUsed a fixed regexUse re.search(r"[^A-Za-z0-9]", password) for “any non-alphanumeric”
Logs/print contain the passwordDebugging left inNever log passwords; never even print echoes
Comparing in plain textCustom logicAlways hash before comparing/storing

Reject keyboard runs (qwerty, asdf), repeated characters (aaa), and sequences (12345).

pattern.py
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()))

A 4-digit substring matching 19xx or 20xx is almost certainly a birth year — flag it.

Accept the user’s name, email, and birth date; reject any password containing those.

A Tkinter form with a colored progress bar that shifts red → yellow → green as the user types. See Calculator GUI for the layout pattern.

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.

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?

If the password is too short, append a random word. If a class is missing, insert one. If common, regenerate entirely.

Cross-reference with Random Password Generator. Show the strength of every generated password.

Read a config file specifying minimums (length, classes, entropy, max breach count) and enforce them.

cli
strength check "Tr0ub4dor&3"
strength suggest --length 16 --no-ambig

Security 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.
  • 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.
  • 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.
  • Implement the common-password blacklist above.
  • Add HIBP k-anonymity check.
  • Switch to zxcvbn for 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.

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.

  • The elif chain reports one problem at a time. password fails 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 toward Password1@ 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 an if is a script, not a function.
  • Seven test passwords, run on this machine: five rejected, two accepted.
  • The elif chain 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.
pch.quizTag pch.quizDefaultTitle
  1. 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

  2. `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

  3. 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

  4. 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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading