A junior dev on my team once wanted to add a “check if your password was breached” feature to our signup form. His first instinct: POST the plaintext password to Have I Been Pwned and show a warning if it came back dirty. I stopped him before the PR got anywhere. Sending a user’s raw password to a third party to prove it’s not compromised is the kind of irony that ends up in a postmortem.
The good news is that HIBP solved this exact problem years ago with a technique called k-anonymity, and it’s genuinely clever. You can check any password against 900+ million breached credentials without ever sending the password, its full hash, or anything that identifies it. I’ll walk through how it works, show the actual bytes on the wire, and explain why this is one of the few “phone home” security checks I trust in a browser.
The problem with a naive breach check
The obvious design is: hash the password, send the hash, get back a yes/no. But a SHA-1 hash of a password isn’t anonymous. SHA-1 is fast and unsalted here, and breach corpuses are massive. If you send the full hash 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, the server (or anyone sniffing the request) can reverse it in microseconds against a rainbow table. That hash is literally the word password. You’ve leaked the credential.
You need a way to ask “is this password in your list?” where the server learns nothing useful about which password you asked about. That’s what k-anonymity buys you.
How the range API actually works
The trick is to send only the first 5 characters of the SHA-1 hash. Here’s the full flow for the password password:
SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
└─┬─┘└──────────────┬──────────────────┘
prefix (5) suffix (35)
GET https://api.pwnedpasswords.com/range/5BAA6
You send 5BAA6. The server responds with every breached-hash suffix that shares that prefix — the tail 35 hex characters plus a breach count, one per line:
003D68EB55068C33ACE09247EE4C639306B:29
00658BFD1E05761042698D19D32CD9F1A8F:15
...
1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427
...
That last line is the one you care about. Your browser (not the server) scans the response for your suffix 1E4C9B93F3F0682250B6CF8331B7EE68FD8, finds it, and reads the count: 52,372,427. The word “password” has appeared in 52 million breached records.
The server never saw which suffix you were looking for. It handed back roughly 800–1,000 candidates and let you do the final match locally. When I hit that prefix, I got 1,977 hash suffixes back. Any one of them could have been “your” password. That’s the anonymity set.
Doing it yourself in ~15 lines
No API key, no rate limit worth worrying about, and CORS is wide open so this runs fine from browser JavaScript. Here’s the whole thing in Python so you can see there’s no magic:
import hashlib, urllib.request
def pwned_count(password):
h = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = h[:5], h[5:]
url = f"https://api.pwnedpasswords.com/range/{prefix}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
body = urllib.request.urlopen(req).read().decode()
for line in body.splitlines():
s, count = line.split(":")
if s == suffix:
return int(count)
return 0
print(pwned_count("password")) # 52372427
print(pwned_count("123456")) # 210461208
print(pwned_count("correcthorsebatterystaple")) # 4173
print(pwned_count("xK9#mQ2vLp8$wZ4nR7tB")) # 0
Those are real numbers I pulled today, not made up. A couple of them are worth sitting with. 123456 shows up 210 million times — it’s the single most breached string on the internet. And the famous XKCD passphrase correcthorsebatterystaple? Pwned 4,173 times. The moment a password becomes advice, it becomes a dictionary entry. Randomness is the only thing that keeps you at zero.
The JavaScript version is nearly identical, using the built-in crypto.subtle.digest("SHA-1", ...). This is exactly the kind of thing SubtleCrypto is good at — unlike MD5, which the Web Crypto API flatly refuses to compute. (I wrote a whole teardown of why Web Crypto won’t do MD5 if you want that rabbit hole.)
The padding option most people miss
There’s a subtle leak in the basic scheme. Response sizes vary — a prefix might return 400 suffixes or 1,200. A network observer counting bytes can sometimes narrow down which prefix you requested, and popular prefixes correlate with common passwords. HIBP added a fix: send the header Add-Padding: true and the server pads every response with a random number of fake, zero-count entries.
curl -s "https://api.pwnedpasswords.com/range/5BAA6" \
-H "Add-Padding: true" -H "User-Agent: Mozilla/5.0"
# ...real entries...
DBB7A2BC0BCFAC5BF1E8B50FFC97A118303:0 ← decoy
...
When I added the header, the response grew from 1,977 to 2,122 lines — 144 of them decoys with a count of :0. Your matching code already ignores anything with count zero, so the padding is invisible to you but blows up traffic-analysis attacks. If you’re building this into a product, turn padding on. It costs a few KB.
Why browser-only matters here
k-anonymity protects you from the HIBP server, but it doesn’t protect you from your own backend if you route the check through it. The cleanest design is to hash and query entirely client-side, so the plaintext never leaves the tab. That’s the same principle behind every tool I build here — the file, the password, the hash never touches a server I control.
Our HashForge hash generator computes SHA-1 (and SHA-256, and yes, even MD5) locally in the browser, which is exactly the primitive you need for the prefix step. Pair it with a real random generator instead of a memorable-but-guessable passphrase, and you close the loop. Our password generator uses crypto.getRandomValues() rather than Math.random() — the difference between those two is a genuinely scary gap I’ve written about before.
One gotcha: this is a filter, not a verdict
A count of zero doesn’t mean a password is strong — it means it hasn’t leaked yet. Tr0ub4dor&3 might return zero and still fall to a targeted attack in seconds because its structure is predictable. Breach-checking is a floor, not a ceiling. Use it to reject known-compromised passwords at signup, then rely on length and true randomness for actual strength. NIST’s SP 800-63B guidance says exactly this: screen against breach corpuses, drop the forced-rotation and complexity theater, and let users pick long random strings.
If you want to run this at scale in your own infra, a hardware security key makes the whole password question moot for the accounts that matter. I keep a YubiKey 5 NFC on my keychain for exactly that reason (full disclosure: affiliate link — it’s the one I actually carry). For everything else, the k-anonymity check is 15 lines and a free API away.
Go hash something. Start with your own most-reused password and see what number comes back. If it’s not zero, you’ve got a weekend project.
Want more field notes on security, tooling, and markets? Join https://t.me/alphasignal822 for free market intelligence.
📧 Get weekly insights on security, trading, and tech. No spam, unsubscribe anytime.
Leave a Reply