Breached Password Screening With k-Anonymity
The single most effective password policy is not a complexity rule; it is refusing passwords that are already published. Credential stuffing works because people reuse credentials, and every reused credential in your user base is a successful login waiting for someone with the right list. Screening at password-set time removes that inventory, and it does so without asking users to memorise a symbol requirement that pushes them toward predictable substitutions.
This guide implements screening with k-anonymity range queries, so the password never leaves your process, plus the self-hosted variant for environments where an external call in the credential path is unacceptable. It is part of the Password Storage & Credential Hygiene guide within Secure Authentication & Session Architecture, and it is the supply-side complement to rate limiting login endpoints.
Prerequisites
- A password-set path you control: registration, change and reset all route through it
- Outbound network access from that path, or disk space for a local corpus
- A cache with a short lifetime for repeated prefixes
- A decision, made before implementation, about what happens when the corpus is unreachable
Expected Outcomes
- Known-breached passwords refused at the moment they are chosen
- No password and no full digest ever transmitted off the host
- Screening latency bounded and cached, with a hard timeout
- A deliberate, documented failure mode for corpus unavailability
Step 1: Hash Locally, Send a Prefix, Compare at Home
The protocol is three lines of logic and one property worth understanding: the bucket you request contains hundreds of entries, and only you know which one you were asking about.
import hashlib, httpx
RANGE_URL = "https://api.pwnedpasswords.com/range/{prefix}"
async def breach_count(password: str, client: httpx.AsyncClient) -> int:
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix, suffix = digest[:5], digest[5:] # only the prefix ever leaves the process
resp = await client.get(RANGE_URL.format(prefix=prefix), timeout=2.0,
headers={"Add-Padding": "true"}) # padding hides bucket size
resp.raise_for_status()
for line in resp.text.splitlines(): # the bucket: many candidate suffixes
candidate, _, count = line.partition(":")
if candidate == suffix: # the comparison happens here, locally
return int(count)
return 0
Two details are easy to skip and worth keeping. Requesting padding means every response is a similar size, so a network observer cannot infer bucket size from response length. And the timeout is short and explicit, because this call sits in a user-facing path where a hanging dependency is a broken registration form.
Step 2: Apply It at Set Time, With a Useful Message
MIN_LENGTH = 12
BREACH_THRESHOLD = 1 # any appearance is enough; the corpus is not a popularity contest
async def validate_new_password(user, password: str, client):
if len(password) < MIN_LENGTH:
raise ValidationError(f"Use at least {MIN_LENGTH} characters.")
if password.lower() in derived_from_profile(user): # name, email local part, company
raise ValidationError("This is too close to information on your account.")
try:
count = await breach_count(password, client)
except (httpx.TimeoutException, httpx.HTTPError):
metrics.increment("breach_screen.unavailable")
if SCREENING_REQUIRED: # decided in advance, see Step 3
raise ServiceUnavailable("Please try again shortly.")
flag_for_recheck(user) # allowed, but revisit later
return
if count >= BREACH_THRESHOLD:
raise ValidationError(
"This password has appeared in a public breach. Choose one you have not used elsewhere."
)
The message is part of the control. “Appeared in a public breach — choose something you have not used elsewhere” tells the user what happened and what to do; “does not meet complexity requirements” sends them to append an exclamation mark. Never echo the password back and never show the count, which only invites curiosity about the corpus.
Step 3: Choose the Failure Mode Before You Need It
Screening is an external dependency in the credential path, so decide what an outage means.
| Context | Reasonable choice | Reasoning |
|---|---|---|
| Consumer product registration | Allow, flag, re-check later | Blocking sign-up over a third-party outage costs more than the residual risk |
| Password change for an existing account | Allow, flag, re-check at next login | The account already exists; a weak new password is recoverable |
| Administrative or privileged accounts | Block | The population is small, the blast radius is large, and the delay is tolerable |
| Regulated environment with a mandate | Block, or self-host so the dependency is local | The requirement is to screen, not to screen when convenient |
The flag matters as much as the decision: an allowed-under-outage password should be re-screened on the next successful login, so a temporary outage does not permanently exempt a credential.
Step 4: Self-Host When the Dependency Is Unacceptable
For high-assurance or high-volume systems, keep the corpus local. It is static data, so a probabilistic filter answers in microseconds with a small memory footprint.
from pybloom_live import ScalableBloomFilter
# Built once from the corpus, memory-mapped at boot; ~1% false positive rate.
BREACH_FILTER = load_filter("/opt/corpora/breached-sha1.bloom")
def is_breached_local(password: str) -> bool:
digest = hashlib.sha1(password.encode()).hexdigest().upper()
if digest not in BREACH_FILTER:
return False # definitively absent
return exact_lookup(digest) # possible match: confirm against the sorted index
A false positive here rejects a password that is not actually breached, which is a mild inconvenience and never a security failure — but confirming against an exact index keeps even that from happening. Schedule the corpus refresh explicitly, because a stale corpus quietly degrades into no screening at all.
Verification
# 1. A famously breached password must be refused.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/account/password \
-H "Cookie: $SESSION" -H 'Content-Type: application/json' \
-d '{"password":"Password123!"}'
# Expected: 400 with a breach-specific message.
# 2. A strong, unique passphrase must be accepted.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/account/password \
-H "Cookie: $SESSION" -H 'Content-Type: application/json' \
-d '{"password":"tram-vellum-quiet-harbour-92"}'
# Expected: 204.
# 3. Nothing sensitive crosses the wire: inspect the outbound request.
sudo tcpdump -A -s0 -i any 'host api.pwnedpasswords.com' | grep -iE 'GET /range/[0-9A-F]{5}'
# Expected: exactly five hexadecimal characters in the path, and nothing else.
Add a unit test that asserts the request path contains only a five-character prefix — a refactor that “simplifies” the code by sending the whole digest is otherwise invisible in review.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Registration occasionally hangs | No timeout on the range request | Set a short explicit timeout and handle it as an outage, not an error page |
| Every password is rejected | Digest case mismatch between local computation and the corpus | Upper-case the hexadecimal digest before comparing |
| Screening rejects nothing at all | Response parsed as JSON when it is line-delimited text | Split on newlines and partition each line at the colon |
| Latency spikes at peak | Every request hits the network | Cache buckets briefly; repeated common prefixes are frequent |
| Users complain about unclear errors | Generic validation message | State that the password appeared in a public breach and what to do instead |
| Local filter reports matches for good passwords | Bloom false positives without an exact confirmation step | Confirm every filter hit against the exact index |
Common Implementation Mistakes
Frequently Asked Questions
Does sending a hash prefix leak the password?
A five-character prefix identifies a bucket of several hundred candidate digests, and the comparison of the remaining characters happens entirely inside your process. The service learns that someone asked about a bucket — not which entry was of interest, and certainly not the password. With response padding enabled, even the size of the reply reveals nothing. The residual signal is that a password was being set, which the request itself already implies.
Should screening run at login as well as at password set?
Screen at set time, and treat a login-time match as a signal rather than a block. Corpora grow, so a password that was clean when chosen may appear later; refusing the login at that moment locks a legitimate user out of an account they still own, with no path to fix it. Authenticate them, then require a change before they continue — optionally behind a second factor if the risk signals warrant it.
When is self-hosting the corpus worth it?
When an external dependency in the password-set path is unacceptable: regulated environments, air-gapped deployments, or very high volumes where per-request latency matters. The corpus is large but static, so a local filter plus an exact index answers in microseconds with no network call and no third-party availability risk. The trade is that the refresh cycle becomes yours to schedule — and a corpus nobody refreshes decays into no screening at all.
Related
- Password Storage & Credential Hygiene — the parent guide covering hashing, peppering and rehash
- Argon2id Parameter Tuning for Web Applications — the offline-attacker control this page complements
- Rate Limiting Login Endpoints Against Credential Stuffing — throttling the attack that reused passwords enable
- MFA Enforcement Patterns — making a correct password insufficient by itself