toolhq.io

All posts
5 min readby Jameel Haider

How breach lookup works with k-anonymity

A breach check tells you whether a password has appeared in a known data leak. The obvious way to build that, sending the password to a server that looks it up, would be reckless: you would be transmitting the exact secret you are trying to protect. The Have I Been Pwned Pwned Passwords service solves this with a model called k-anonymity, where you get the answer without the server ever learning your password or even its full hash.

The problem with a naive lookup

Imagine querying GET /check?password=hunter2. Now your plaintext password is in the server's logs, in transit, and in any proxy along the way. Hashing it first and sending the full hash is barely better: the server learns the exact hash, and because breach databases map hashes back to common passwords, that is often as good as learning the password.

The goal is to check membership in a set of hundreds of millions of leaked hashes while revealing almost nothing about which one you are asking about.

The k-anonymity trick

The password is hashed locally with SHA-1, giving a 40 character hex digest. The client then splits it:

SHA1("P@ssw0rd") = 21BD12...  (40 hex chars)
                    ^^^^^      first 5 chars = prefix
                         ^^^^  remaining 35 chars = suffix

Only the first five hex characters are sent to the API:

GET https://api.pwnedpasswords.com/range/21BD1

The server responds with every leaked hash suffix that begins with that prefix, typically a few hundred to a thousand of them, each with a count of how many times it was seen:

0018A45C4D1DEF81644B54AB7F969B88D65:1
00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2
...

The client searches that list locally for its own 35 character suffix. If the suffix is present, the password is in the breach set and the count tells you how many times. If it is absent, it is not known to be breached.

Why the server learns nothing useful

The five character prefix is shared by a large bucket of distinct hashes, so the server cannot tell which password you were checking. That bucket is the anonymity set, the k in k-anonymity: your query is indistinguishable from any of the k possible passwords that share the prefix. The full hash never leaves your machine, the matching happens on your side, and the server sees only a prefix that fits a crowd.

you send:     a 5-char prefix (ambiguous)
server sends: the whole bucket of suffixes
you match:    your suffix, locally

A small refinement called padding adds random decoy entries so the response size does not leak how many real hashes share a prefix.

Why SHA-1 is acceptable here

SHA-1 is broken for digital signatures, where an attacker can craft collisions. For this use it is fine, because the hash is only a lookup key into a published list, not a secret being protected. There is no signature to forge and no security claim resting on collision resistance. The scheme's privacy comes from the prefix split, not from the strength of the hash.

A browser based strength checker can apply the same principle: compute the hash on your device and send only a prefix, so the password you test is never transmitted in full. The password strength checker does its analysis locally, keeping your input on your device. The lesson generalizes beyond breach lookups: when you must query a service about a secret, send the smallest ambiguous fragment and finish the match on your own side. For the properties of the hashes involved, see MD5 vs SHA-1 vs SHA-256.