Index Ai Academy
Phase 7 · Software engineering · Python projects

Project: a private password-breach check

Hash locally, send five characters, match locally: k-anonymity, safe logging and failure handling.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Why not just send the hash?
  2. Asking the range route
  3. Read a range answer
  4. Keep the password out of everything
  5. Worked example: proving the password never leaves
Quick check
How can you check whether a password is in a public list of leaked passwords without revealing it to the list's owner?
  1. Send the password itself, over an encrypted connection
  2. Ask the user whether they think it has leaked
  3. Send the full hash of the password instead
  4. Send just the start of its hash, and compare the rest yourself
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")
It runs right here in your browser, with nothing to install.

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.

Try it now

Follow the password: it's hashed, split, and only the first 5 characters would ever leave your machine.

Quick check
A sign-up page sends the first 5 characters of a password's SHA-1 hash to a breach-check service. What can the service learn?
  1. The full hash, for a dictionary attack
  2. Only a prefix that hundreds of passwords share
  3. The password, by reversing the 5 characters
  4. Which user is signing up, from the hash
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.

Quick check
What does this print?
import hashlib
digest = hashlib.sha1("Trailhead2024".encode()).hexdigest().upper()
prefix, suffix = digest[:5], digest[5:]
print(len(prefix), len(suffix), prefix + suffix == digest)
  1. 5 35 True
  2. 40 35 True
  3. 5 35 False
  4. 5 40 True
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.

Drill

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))
In the lesson, your code is checked as you write it.

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.

Quick check
Which log line is acceptable in a sign-up service that checks passwords against breaches?
  1. "hash 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 found 3210442 times"
  2. "GET /v1/breaches/range/5BAA6 for user 4821 with password summer2026"
  3. "checking password summer2026 for user 4821"
  4. "password rejected: found in breaches" with the user id
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

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"]))
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why do breach-check services pad their range answers with decoy lines?

  1. So the answer's size doesn't hint at the prefix
  2. To make the answers harder for anyone to parse
  3. Because SHA-1 answers need exactly 24 lines
  4. To slow attackers down with longer downloads

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson