DEV Community

Max
Max

Posted on • Originally published at orthogonal.info

Check If a Password Was Breached Without Sending It (HIBP k-Anonymity)

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 warn the user 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 isn't 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. Let me 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 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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Those are real numbers I pulled today, not made up. A couple are worth sitting with. 123456 shows up 210 million times — 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", ...):

async function pwnedCount(password) {
  const buf = await crypto.subtle.digest(
    "SHA-1", new TextEncoder().encode(password)
  );
  const hash = [...new Uint8Array(buf)]
    .map(b => b.toString(16).padStart(2, "0")).join("").toUpperCase();
  const prefix = hash.slice(0, 5), suffix = hash.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
  const body = await res.text();
  for (const line of body.split("\n")) {
    const [s, count] = line.trim().split(":");
    if (s === suffix) return parseInt(count, 10);
  }
  return 0;
}
Enter fullscreen mode Exit fullscreen mode

This is exactly the kind of thing SubtleCrypto is good at — unlike MD5, which the Web Crypto API flatly refuses to compute.

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
...
Enter fullscreen mode Exit fullscreen mode

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: the file, the password, the hash never touches a server I control. I wrote up the full teardown with a live browser demo here if you want to poke at the bytes yourself.


One thing this whole exercise reframed for me: a "check if breached" feature is only as trustworthy as its data flow. The clever part isn't the hashing — it's that the question itself is anonymized.

What's the sketchiest "we'll just send it to a third party to be safe" security feature you've seen ship? I've got a couple of horror stories.

Top comments (1)

Collapse
 
ismail_hossain profile image
Ismail Hossain

good write!