Project: a private password-breach check
Hash locally, send five characters, match locally: k-anonymity, safe logging and failure handling.
Create a free account Try part of it here
Why it matters
Billions of passwords from past breaches are public, and attackers try them first. So a good sign-up form refuses a password that has already leaked, which raises an awkward question: how do you check a password against someone else's list without handing them the password? The answer is a small protocol used by public breach-check services, built in this project from hashing, an HTTP request and a dictionary. It's also a fine example of designing for privacy: the server can answer without ever learning what you asked.
What you'll be able to do
- Explain why sending a password, or even its plain hash, to a checking service is unsafe
- Check a password against a breach list with a k-anonymity range query: hash locally, send 5 characters, match locally
- Design the check so the password never appears in a log, a URL or an error message
- Turn the result into a sign-up rule, and decide what happens when the service is down
What it covers
- Why not just send the hash?
- Asking the range route
- Read a range answer
- Keep the password out of everything
- Worked example: proving the password never leaves
Show the answer
Send just the start of its hash, and compare the rest yourself
A short prefix of the hash returns hundreds of possible matches, and you check them yourself, so the service never learns which password you had. A full hash can be cracked, and the password itself is out of the question.
Why not just send the hash?
Sending the password itself is obviously out. Sending its hash looks safer, but isn't: breach lists are mostly common passwords, and a hash of a common password can be reversed by guessing, the dictionary attack from the security lesson. Whoever runs the service, or anyone who sees the request, would learn the password the moment it's on any list.
The trick is to send less than the hash. Hash the password with SHA-1 (the hash breach lists are published in), and write it as 40 hexadecimal characters. Send only the first 5 to the service. It answers with every hash it knows that starts with those 5 characters, hundreds of them in a real service, and you look for the remaining 35 characters in that answer on your own machine. The service sees a prefix shared by hundreds of unrelated passwords, so it can't tell which one you were checking (only for an extremely common password could it make a good guess). Hiding one item in a crowd of similar ones like this is called k-anonymity.
import hashlib password = "summer2026" digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() prefix, suffix = digest[:5], digest[5:] print(digest) print("send only:", prefix, "| keep secret:", suffix) print(16 ** 5, "possible prefixes, so each covers about", round(1_000_000_000 / 16 ** 5), "of a billion leaked hashes")
With about a billion leaked passwords spread over 16⁵ = 1,048,576 prefixes, each prefix matches around 950 hashes on average. The service learns that your password's hash is one of those (or none), which tells it very little.
Go deeper Isn't SHA-1 broken?
SHA-1 is broken for one job: researchers can build two different files with the same SHA-1 hash, so it mustn't be used for signatures or certificates. Here it's used for something else, as the agreed name for each leaked password in a published list, and the protocol's privacy comes from sending only 5 characters, not from SHA-1's strength. It's also no way to store passwords: your own database still uses a slow, salted hash, as the security lesson showed. Two hashes, two jobs.
Follow the password: it's hashed, split, and only the first 5 characters would ever leave your machine.
Show the answer
Only a prefix that hundreds of passwords share
Five hex characters can't be reversed into a password: about a million different prefixes are shared by everything ever leaked. The matching happens on your side, with the 35 characters you kept.
import hashlib digest = hashlib.sha1("Trailhead2024".encode()).hexdigest().upper() prefix, suffix = digest[:5], digest[5:] print(len(prefix), len(suffix), prefix + suffix == digest)
Show the answer
5 35 True
SHA-1 gives 20 bytes, written as 40 hex characters. The first 5 are sent, the other 35 stay local, and together they're the whole hash again.
Asking the range route
The practice API has a range route built the same way as the real services: GET /v1/breaches/range/<prefix> answers with plain text, one line per known hash with that prefix, written as SUFFIX:COUNT, where the count is how many times that password turned up in leaks. It also pads every answer with decoy lines whose count is 0, so even the size of the response gives little away. Real services do the same when a client asks for it with an Add-Padding: true header.
import hashlib import requests def range_lines(prefix): r = requests.get(f"https://api.gradient.test/v1/breaches/range/{prefix}", timeout=10) r.raise_for_status() return r.text.splitlines() digest = hashlib.sha1("summer2026".encode()).hexdigest().upper() lines = range_lines(digest[:5]) print(len(lines), "lines, for example", lines[:2]) for line in lines: found, count = line.split(":") if found == digest[5:]: print("found: this password appeared", int(count), "times")
r.text is the response as a string, and splitlines cuts it into lines, whichever line ending the server used. The matching is one string comparison per line: no database, no machine learning, just the right protocol.
Read a range answer
Write parse_range(text): turn a range answer into a dictionary from suffix (upper case) to count (an int). Lines look like SUFFIX:COUNT; skip blank lines, and treat the suffix without regard to case, since some services answer in lower case.
def parse_range(text): return {} answer = "0018A45C4D1DEF81644B54AB7F969B88D65:3\r\n00D4F6E8FA6EECAD2A3AA415EEC418D38EC:12\r\n\r\n011053fd0102e94d6ae2f8b83d76faf94f6:1" print(parse_range(answer))
Keep the password out of everything
The protocol protects the password on the way to the service. The rest of your code has to protect it too, because passwords often leak through logs rather than through clever attacks:
- Never log it, not in a debug line, not in an error message. Log the outcome ("password rejected: found in breaches") and nothing that identifies the password, not even the full hash.
- Never put it in a URL. URLs end up in server logs, browser history and analytics. The prefix is the only part that goes into the request, and that's by design.
- Decide what a failure means. If the service is down, do you refuse every sign-up (fail closed) or allow it and check later (fail open)? Some sign-up forms fail open and check later; this project fails closed on purpose, asking the member to try again, because a breached password let through is hard to take back. Either way, the choice is written down, not an accident of an unhandled exception.
Making the network call a parameter, a function passed in, keeps those rules testable: a test can pass a fake that records exactly what was sent, with no network at all.
In a real sign-up flow
Run the check on your server, where the password arrives anyway, so the browser doesn't need to call a third party. Run it at sign-up and at every password change, and refuse a breached password with a message that says why and suggests a long passphrase instead of listing rules. Answers for a prefix rarely change, so caching them for a day cuts both latency and calls to the service. And keep the check next to, not instead of, the other defenses: slow salted hashing in storage, rate limits on login attempts, and a second factor (a code from an app or a security key, on top of the password) for accounts that matter.
Show the answer
"password rejected: found in breaches" with the user id
The outcome is useful and safe to log. The password, its full hash (reversible for a common password) and any line combining them with a user id turn the log into a breach of its own.
Worked example: proving the password never leaves
Pass the network call in as a function, and a test can record everything that would have been sent. Here a fake service answers from a tiny made-up list and keeps a list of every request.
import hashlib sent = [] def fake_fetch(prefix): sent.append(prefix) known = {hashlib.sha1(p.encode()).hexdigest().upper(): n for p, n in [("password", 3210442), ("letmein", 640117)]} return "\n".join(f"{h[5:]}:{n}" for h, n in known.items() if h.startswith(prefix)) def times_breached(password, fetch): digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() for line in fetch(digest[:5]).splitlines(): suffix, count = line.split(":") if suffix.upper() == digest[5:]: return int(count) return 0 print(times_breached("password", fake_fetch), times_breached("pine-gap-ridge-lantern-94", fake_fetch)) print("everything sent:", sent) print("any password in it?", any(p in s for s in sent for p in ["password", "pine-gap-ridge-lantern-94"]))
times_breached never knows whether it's talking to the real service or the fake one: it just calls fetch. That's dependency injection, and it lets the test check the privacy promise directly. The list of everything sent holds two 5-character prefixes and nothing else. The same design lets the real code swap in a cached fetch, or one with a timeout and retries, without touching the matching logic.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why do breach-check services pad their range answers with decoy lines?