🛡️ Check Passwords Against Breaches Without Leaking Them: k-Anonymity & the HIBP API
Rejecting weak passwords at signup is table stakes. The harder, more valuable check is whether a password has already appeared in a real-world data breach — because a credential that is strong on paper is worthless if attackers already have it in a credential-stuffing wordlist. The obvious way to check is to send the password somewhere and ask. The problem is equally obvious: you should never transmit a user's plaintext password, or even its full hash, to a third party. k-anonymity resolves that tension, and this guide shows exactly how to implement it.
Why breached-password screening matters
Password strength meters measure guessability in the abstract. Breach screening measures a different and more urgent risk: reuse. Once a password leaks from one service, it is added to enormous corpora that attackers replay against every other login form on the internet. This is credential stuffing, and it succeeds precisely because people reuse passwords that were strong until the day they were exposed.
Regulators and standards bodies now treat breach screening as a baseline control rather than a nice-to-have. The guidance is explicit:
“When processing requests to establish and change memorized secrets, verifiers SHALL compare the prospective secrets against a list that contains values known to be commonly-used, expected, or compromised.” — NIST Special Publication 800-63B, Digital Identity Guidelines
The OWASP Authentication Cheat Sheet echoes this, recommending that applications screen new passwords against a list of known-breached credentials at registration and at every password change. The largest freely available corpus is the Pwned Passwords dataset from Have I Been Pwned, which holds more than 850 million real-world compromised password hashes drawn from thousands of breaches. The question is how to query a dataset that large without either downloading gigabytes locally or leaking the password you are trying to protect.
How k-anonymity works, step by step
The Pwned Passwords range API solves both problems at once. Instead of asking “is this password breached?”, your application asks “give me every breached hash that starts with these five characters,” then finishes the comparison locally. Here is the full flow:
- Compute the SHA-1 hash of the password and uppercase it. (SHA-1 is used here purely as a lookup key against the corpus, not for storage — you still store passwords with a slow algorithm like Argon2id.)
- Split the 40-character hash into a 5-character prefix and a 35-character suffix.
- Send only the prefix to
https://api.pwnedpasswords.com/range/{prefix}. - The API returns roughly 300–900 suffixes that share that prefix, each with a breach count.
- Search that response locally for your suffix. A match means the password is compromised; the number tells you how many times it has been seen.
Because a single five-character prefix maps to hundreds of distinct hashes, the server cannot know which one you were interested in. That anonymity set — the “k” in k-anonymity — is what keeps the query private.
| What leaves your server | Naive approach | k-anonymity approach |
|---|---|---|
| Plaintext password | Sometimes | Never |
| Full password hash | Yes | Never |
| Data transmitted | Full 40-char hash | First 5 chars only |
| Candidate hashes returned | 1 (an answer) | ~300–900 (an anonymity set) |
| Can the server identify the password? | Yes | No |
Implementation in Node.js
The entire check is a hash, a slice, and one HTTPS request. No API key is required for the range endpoint. Add the recommended Add-Padding: true header so responses are padded to a uniform size, defeating traffic-analysis attacks that might otherwise infer results from payload length.
import crypto from "node:crypto";
export async function pwnedCount(password) {
const hash = crypto.createHash("sha1")
.update(password).digest("hex").toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const res = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`,
{ headers: { "Add-Padding": "true", "User-Agent": "rpt-breach-check" } }
);
if (!res.ok) throw new Error(`HIBP ${res.status}`);
for (const line of (await res.text()).split("\n")) {
const [candidate, count] = line.trim().split(":");
if (candidate === suffix) return Number(count);
}
return 0; // 0 = not found in any known breach
}
// Reject compromised passwords at signup / password change:
if (await pwnedCount(candidate) > 0) {
throw new Error("This password has appeared in a data breach. Choose another.");
}
Implementation in Python
import hashlib, requests
def pwned_count(password: str) -> int:
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
r = requests.get(
f"https://api.pwnedpasswords.com/range/{prefix}",
headers={"Add-Padding": "true", "User-Agent": "rpt-breach-check"},
timeout=5,
)
r.raise_for_status()
for line in r.text.splitlines():
candidate, _, count = line.partition(":")
if candidate == suffix:
return int(count)
return 0
Implementation in Go
package breach
import (
"bufio"
"crypto/sha1"
"fmt"
"net/http"
"strconv"
"strings"
)
func PwnedCount(password string) (int, error) {
sum := sha1.Sum([]byte(password))
hash := strings.ToUpper(fmt.Sprintf("%x", sum))
prefix, suffix := hash[:5], hash[5:]
req, _ := http.NewRequest("GET",
"https://api.pwnedpasswords.com/range/"+prefix, nil)
req.Header.Set("Add-Padding", "true")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
parts := strings.SplitN(scanner.Text(), ":", 2)
if strings.TrimSpace(parts[0]) == suffix {
n, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
return n, nil
}
}
return 0, nil
}
Interpreting the breach count
The number the API returns beside a matching suffix is not decoration — it is the count of times that password has been observed across all ingested breaches, and it should drive policy. A password seen a handful of times is already circulating; a password seen millions of times, like 123456 or password, sits at the very top of every attacker's dictionary and will be tried within the first few guesses of any credential-stuffing run.
Most teams treat any non-zero count as a hard rejection, which is the safest default and aligns with the NIST guidance quoted above. If you need a softer rollout — for example, screening existing users without forcing an immediate reset — you can tier the response: block outright above a threshold, warn and encourage a change in the middle band, and silently log low counts for later review. Whatever thresholds you choose, apply them consistently and communicate clearly. A vague “password not allowed” error frustrates users; telling them the password has appeared in a public breach and inviting them to pick a fresh one turns a blocked action into a teachable security moment.
| Breach count | Risk | Suggested action |
|---|---|---|
| 0 | Not in known corpus | Allow |
| 1–100 | Exposed, lower volume | Reject at signup; warn existing users |
| > 100 | Widely circulated | Reject unconditionally |
Production considerations
Reference implementations are the easy part. Shipping this safely to real users means handling the failure modes:
- Fail open, not closed. If the range API is unreachable, do not block the user from setting a password — log the outage and allow the change. Breach screening is a defence-in-depth layer, and downtime should never lock people out of their own accounts.
- Add a timeout and cache. A five-character prefix has only 165 possible values. Caching responses for a short window cuts latency and outbound traffic dramatically on high-traffic signup forms.
- Always send
Add-Padding: true. Without padding, an eavesdropper who sees the response size can narrow down whether a match existed. Padding normalises every response to the same length. - Screen at the right moments. Check at registration, at password change, and optionally on next login after a known corpus update — not on every single authentication, which wastes calls and adds latency to your hot path.
- Self-host for zero external calls. For high-volume or air-gapped systems, download the full hash set and serve the range lookup internally. The k-anonymity protocol is identical; only the endpoint changes.
- Never store the SHA-1. The SHA-1 here is a transient lookup key. Persist the password only as a slow, salted hash such as Argon2id or bcrypt.
Where this fits in a real defence stack
Breach screening answers one narrow question — “is this exact password already burned?” — and it answers it well. But it is not a substitute for generating strong, unique credentials in the first place. The most reliable way to guarantee a password has never been breached is to generate a fresh random one per site and never reuse it. A dedicated manager such as NordPass creates and stores those unique passwords with zero-knowledge encryption, so a leak at one service can never cascade into another. For monitoring whether your accounts have surfaced in a breach after the fact, endpoint suites like Kaspersky bundle continuous breach and identity monitoring alongside malware protection.
Layered together, the picture is clear: generate unique passwords, store them with a slow hash, screen every new password against the breach corpus using k-anonymity, and pair it all with multi-factor authentication. Each layer covers a different failure mode. k-anonymity is the one that lets you consult a database of 850 million compromised secrets without ever whispering your users' passwords to anyone.