How to Check Passwords Against Breach Databases Without Sending Passwords
The requirement arrives from your security team looking entirely reasonable. Users shouldn't be allowed to set a password that's already appeared in a breach.
The requirement arrives from your security team looking entirely reasonable. Users shouldn't be allowed to set a password that's already appeared in a breach.
It is reasonable. It's also, on its face, an instruction to take the plaintext password a user just typed and ask a third party whether they've seen it. Which is the single worst thing you could do with that string, and which is exactly what a depressing number of implementations do — POST the password, or an unsalted SHA-1 of it, to an API and trust the response.
There's a technique that gets you the answer without disclosing the password, and it's clever enough to be worth understanding properly rather than copying from a snippet. It also has sharp edges that the snippets don't mention.
Why the naive versions fail
Sending the plaintext needs no analysis. The password is now in someone else's TLS terminator, someone else's access log, and someone else's incident scope.
Sending a hash of the whole password feels like the fix and mostly isn't. Breach corpora are indexed by exactly these hashes — that's the entire point of the dataset. Handing over SHA1(password) gives the recipient a lookup key into a set of billions of known plaintexts. If the password is in the corpus, they can recover it directly; if it isn't, they hold a fingerprint they can grind offline at their leisure. You've built a password-disclosure channel with an extra step.
The bind is structural: to know whether a specific password is in a set held by someone else, one party has to learn something. The technique below doesn't eliminate that. It shrinks what's learned to a quantity that doesn't matter.
The range query
Take the password, SHA-1 it, and split the hex digest at five characters.
password: correct horse battery staple
SHA-1: BF1A48AC5AF1C6A0D9BF8B7A0B0A6A1E45D8D5B3
prefix: BF1A4 (sent)
suffix: 8AC5AF1C6A0D9BF8B7A0B0A6A1E45D8D5B3 (never sent)
Send only the prefix. The server returns every suffix in its corpus beginning with those five characters, along with how many times each was seen:
GET /range/BF1A4
0018A45C4D1DEF81644B54AB7F969B88D65:1
00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2
011053FD0102E94D6AE2F8B83D76FAF94F6:1
...
8AC5AF1C6A0D9BF8B7A0B0A6A1E45D8D5B3:37
...
Around 800 lines, typically. You scan the list locally for your suffix. Found it — that password has appeared 37 times in breaches. Not found — clean, as far as this corpus knows.
The server saw five hex characters. Twenty bits. It learns that your password is one of roughly 1-in-a-million candidates, which given the size of the plaintext space is not usefully close to knowing anything.
This is a k-anonymity construction: your query is indistinguishable from every other query whose hash shares the prefix. The anonymity set is the bucket, and the bucket is big.
Two properties make it work in practice rather than just in theory. The comparison happens on your machine, so the server never observes a hit or a miss — a critical detail, because a service that knew which answers were positive could correlate over time. And the response size is independent of your query, so there's no length side channel.
Why SHA-1, and why that isn't the problem it looks like
Every review of this raises the same objection: SHA-1 is broken, why is a security control built on it?
SHA-1's break is collision resistance — an attacker can construct two inputs hashing to the same value. That's fatal for signatures and certificates. It's irrelevant here, because nothing in this protocol depends on collisions being hard to find. What you need is that the prefix doesn't reveal the input, and that's preimage resistance, which SHA-1 still has.
The real reason for SHA-1 is boring: the corpora were built with it and rebuilding an index of a billion entries is expensive. Newer services offer NTLM and SHA-256 variants of the same range API.
The genuinely important caveat is different, and it's the one that gets missed: this hash has nothing to do with how you store passwords. It's a lookup key, computed in memory, used once, discarded. Storage still means bcrypt, scrypt, or Argon2id with a proper work factor. I have seen a team read about this technique and conclude that unsalted SHA-1 was acceptable for their password table, which is the worst possible outcome from a well-intentioned feature.
The padding problem
The version above has a leak that the original design missed, and it's a good lesson in how side channels survive a protocol that's correct on paper.
Bucket sizes vary. Common prefixes return 1,200 entries; rare ones return 300. Responses are compressed, and TLS doesn't hide length. So a network observer — a corporate proxy, an ISP, anyone on-path — sees an encrypted response of 34,102 bytes and can match that against a precomputed table of bucket sizes to identify which prefix you asked for. Not the password. But the prefix, which is the entire input you were trying to protect.
The mitigation is padding: the client sends a header requesting it, and the server pads every bucket to a uniform count with synthetic entries.
GET /range/BF1A4
Add-Padding: true
The padded entries are marked with a count of zero. Which creates the second-order bug: if you filter on "count greater than zero," fine, but if your code treats presence in the list as a hit, padding turns every password into a breached one. And because real entries always have positive counts, this fails in the direction of rejecting valid passwords — annoying, visible, and at least not a security hole.
Use padding. Filter on the count, not on presence.
Running it offline
For high-volume or regulated environments, the honest answer is to stop making network calls on the login path at all.
The corpora are downloadable — roughly a billion hashes, in the tens of gigabytes as text. Three viable shapes, in ascending order of effort:
Sorted flat file with binary search. The file is sorted by hash, records are fixed-width. Binary search over an mmap'd file is about 30 disk seeks worst case, single-digit milliseconds on SSD, and needs no database at all. Underrated, and the right answer more often than people expect.
A Bloom filter in memory. A billion entries at a 0.1% false-positive rate is around 1.7GB — small enough to sit in every service instance. Lookups are a handful of hash computations, sub-microsecond, no I/O. False positives mean occasionally rejecting a password that wasn't actually breached, which for this use case is an acceptable error: the user picks a different password and never learns why. False negatives are impossible, which is the direction that matters.
A key-value store. Redis or RocksDB keyed by hash. Simplest to operate, most memory-hungry, and adds a network dependency you were trying to remove.
I'd take the Bloom filter for anything at scale and the flat file for everything else. Both eliminate the third-party dependency on a hot path, which also removes an availability question you'd otherwise have to answer — see the degradation problem: if the breach service is down, do you block registration, or allow it?
(The answer, for what it's worth: allow, log, and re-check asynchronously. This is a hygiene control, not an authentication boundary, and taking down signup because a third-party API is slow is a bad trade. But decide it rather than discovering your HTTP client's default.)
Where to actually apply the check
The technique is the easy part. Where you invoke it determines whether the feature helps or generates support tickets.
At registration and password change — the obvious placement, and the only one where rejecting outright is clearly correct. The user is choosing a password right now and can choose another. Give them a real reason: "this password has appeared in known data breaches, so it's in the lists attackers try first." Not "password does not meet complexity requirements," which is a lie and teaches nothing.
At login, for existing users — more valuable and much more delicate. This is the only moment you'll ever hold the plaintext of a password set years ago, before you had the check. You can verify it, discover it's breached, and act.
What you should not do is block the login. The user is trying to get work done, you're about to lock them out of an account they can access, and you'll deflect them to a password reset flow they may not be able to complete. Authenticate them, then interrupt with a required change — either immediately after login or on a short deadline. Reserve hard blocking for high-count matches on privileged accounts, and expect to argue about the threshold.
In a batch job — you can't. Your stored hashes are salted and slow by design, so there's no offline comparison to run. This is worth saying explicitly because it comes up in every planning meeting: correctly stored passwords cannot be bulk-checked against a breach corpus. Login-time checking exists precisely because it's the only window where the plaintext passes through your process.
What the count is telling you
The occurrence count is the most useful field and the most misread.
A password seen 20 million times is 123456. A password seen twice is probably one person's reused password that appeared in two dumps. Both are "breached," and treating them identically is why users hate this feature.
Thresholds worth defending: block anything above a few hundred occurrences at registration outright. Between one and a few hundred, warn but permit for ordinary accounts, and require a change for administrators and anything privileged. Zero is clean, which is the weakest possible statement — it means this corpus hasn't seen it, not that it's a good password.
That last point deserves emphasis because it's how the control gets oversold internally. A breach check is a filter against known compromised strings. It says nothing about entropy, and Summer2026!Denver will pass cleanly while being trivially guessable by anyone who knows where the company is headquartered. Breach checking and entropy estimation are different measurements, and you want both.
The thing worth taking away
The interesting part isn't the API. It's the shape of the reasoning, which generalizes.
Someone asked a question that seemed to require disclosing a secret to a party who shouldn't have it. The answer wasn't to trust the party, or to accept the leak, or to abandon the feature. It was to find a decomposition where the disclosed quantity is provably insufficient to identify the secret, and where the final comparison happens on the side that already knows it.
That pattern — send an ambiguous prefix, get back a superset, resolve locally — shows up all over privacy engineering once you recognize it. It's the same instinct behind private set intersection, behind Safe Browsing's URL hash prefixes, behind Certificate Transparency's audit paths.
Most of the time, "we have to send it to check it" is a failure of imagination rather than a law of nature.