Checking Passwords Against Breached Lists
How do you reject a new password that already appears in a public data breach — without sending the password, or even its full hash, anywhere? This recipe hashes the candidate with crypto.subtle, sends only the first five hex characters of the SHA-1 digest to a range endpoint through your own origin, compares the returned suffixes locally, and feeds the verdict into setCustomValidity() so the field fails through the same Constraint Validation API path as every other rule. It runs debounced and abortable, and it fails open when the service is slow or down.
When to Use Breach Screening
Breach screening is the check that most improves real-world password security, because attackers try breached passwords first. Use it on:
- Sign-up and change-password forms — every place a user chooses a password.
- Password reset flows — the moment a user is most likely to reuse an old favourite.
- After login, server-side — to flag an existing password that has since appeared in a breach and prompt a change.
Do not run it on the sign-in field before submission. There the user is not choosing anything, and a pre-submit breach warning on a correct password only confuses them. Unlike a strength meter, which advises, a breach hit is a hard rule: the password must be rejected.
Minimal Working Breach Check Implementation
const RANGE_ENDPOINT = "/api/pwned-range/"; // same-origin proxy, cached at the edge
const DEBOUNCE_MS = 500;
const TIMEOUT_MS = 3000;
const BREACH_MESSAGE =
"This password has appeared in a data breach, so attackers are likely to try it. Please choose a different one.";
async function sha1Hex(value: string): Promise<string> {
const data = new TextEncoder().encode(value.normalize("NFKC"));
const digest = await crypto.subtle.digest("SHA-1", data);
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
}
const rangeCache = new Map<string, Promise<Set<string>>>();
function fetchRange(prefix: string, signal: AbortSignal): Promise<Set<string>> {
let hit = rangeCache.get(prefix);
if (!hit) {
hit = fetch(RANGE_ENDPOINT + prefix, { signal, headers: { "Add-Padding": "true" } })
.then((res) => {
if (!res.ok) throw new Error(`range ${res.status}`);
return res.text();
})
.then((body) => new Set(body.split("\n").map((line) => line.split(":")[0].trim())));
hit.catch(() => rangeCache.delete(prefix)); // never cache failures
rangeCache.set(prefix, hit);
}
return hit;
}
export async function isPasswordBreached(value: string, signal: AbortSignal): Promise<boolean | null> {
const hash = await sha1Hex(value);
const timeout = AbortSignal.timeout(TIMEOUT_MS);
try {
const suffixes = await fetchRange(hash.slice(0, 5), AbortSignal.any([signal, timeout]));
return suffixes.has(hash.slice(5));
} catch (err) {
if (signal.aborted) throw err; // superseded by a newer keystroke — let caller ignore
return null; // unknown: service down or timed out → fail open
}
}
// Wiring to the field
const field = document.querySelector<HTMLInputElement>("#new-password")!;
const status = document.querySelector<HTMLElement>("#breach-status")!; // role="status"
let controller: AbortController | undefined;
let timer: number | undefined;
field.addEventListener("input", () => {
controller?.abort();
window.clearTimeout(timer);
// Only withdraw our own verdict; other rules may own the current custom error.
if (field.validationMessage === BREACH_MESSAGE) field.setCustomValidity("");
if ([...field.value].length < 12) return; // local rules first
timer = window.setTimeout(async () => {
controller = new AbortController();
const value = field.value;
status.textContent = "Checking password against known breaches…";
try {
const breached = await isPasswordBreached(value, controller.signal);
if (field.value !== value) return; // stale result
field.setCustomValidity(breached ? BREACH_MESSAGE : "");
status.textContent = breached === null ? "" : breached ? BREACH_MESSAGE : "Not found in known breaches.";
} catch {
/* aborted: a newer check is already scheduled */
}
}, DEBOUNCE_MS);
});
The proxy is a few lines on the server: forward GET /api/pwned-range/:prefix to the public range API, validate that the prefix is exactly five hex characters, and cache responses for a day. Proxying keeps the page free of third-party requests, lets you add rate limiting (see rate-limiting async validation endpoints), and means a content security policy never has to allow an external connect-src.
// server: GET /api/pwned-range/:prefix
export async function pwnedRange(prefix: string): Promise<Response> {
if (!/^[0-9A-F]{5}$/i.test(prefix)) return new Response("bad prefix", { status: 400 });
const upstream = await fetch(`https://api.pwnedpasswords.com/range/${prefix.toUpperCase()}`, {
headers: { "Add-Padding": "true" },
});
return new Response(upstream.body, {
status: upstream.status,
headers: { "content-type": "text/plain", "cache-control": "public, max-age=86400" },
});
}
Breach Check Parameter Reference
| Parameter | Type | Default | Purpose |
|---|---|---|---|
RANGE_ENDPOINT |
string |
/api/pwned-range/ |
Same-origin proxy path; prefix is appended |
| Prefix length | number |
5 |
Fixed by the range API; do not change |
Add-Padding header |
"true" |
on | Pads responses so size does not leak the prefix’s popularity |
DEBOUNCE_MS |
number |
500 |
Wait after the last keystroke before hashing |
TIMEOUT_MS |
number |
3000 |
Give up and fail open after this long |
| Minimum length gate | number |
12 |
Skip the lookup until local rules pass |
| Cache | Map<string, Promise<Set>> |
per page | Avoids refetching when the user edits back and forth |
Verification Steps
import { test, expect } from "@playwright/test";
test("breached password is rejected via custom validity", async ({ page }) => {
await page.route("**/api/pwned-range/*", (route) =>
route.fulfill({ body: "51CC54B60534F68D0F614FCC67950151353:3303\n0000000000000000000000000000000000:0" }),
);
await page.goto("/signup");
const pw = page.getByLabel("New password");
// "passwordpassword" is long enough to pass the local length gate; the mocked
// range response contains its SHA-1 suffix (prefix 476E2), so the lookup reports a breach.
await pw.fill("passwordpassword");
await expect(page.getByRole("status")).toContainText("appeared in a data breach", { timeout: 3000 });
});
Edge Cases and Failure Modes
Stale responses overwrite fresh input. The user types, pauses, the lookup starts, then they keep typing. Without the field.value !== value guard, a late “breached” verdict lands on a different value. Abort on every input and compare values before applying — the same discipline as cancelling stale requests with AbortController.
Failing closed during an outage. If the range service is down and you treat errors as “breached”, nobody can sign up. Return null for unknown and let the server repeat the check against a local corpus after submission.
Unicode normalisation mismatch. Hashing the raw value on one device and a normalised value on another gives different digests for the same visible password. Normalise with NFKC in both the browser and the server before hashing.
Clearing the wrong custom error. Other rules also use setCustomValidity. Only clear the breach message when it is the one currently set; otherwise you would wipe a length or confirmation error that is still valid.
Fail-Open Versus Fail-Closed Breach Screening
Every network-backed validation rule needs an explicit answer to one question: what happens when the network does not answer? For breach screening the answer differs by side. In the browser the check is a courtesy that saves the user a round trip, so it fails open — an unknown result leaves the field valid and the form submittable. On the server the check is policy, so it should fail closed against a local copy of the corpus, which cannot have an outage in the way a remote API can. Getting this backwards produces two opposite incidents: failing closed in the browser locks every new user out during a third-party outage, while failing open on the server silently accepts breached passwords for as long as the outage lasts.
The local corpus is less exotic than it sounds. The full SHA-1 range set can be downloaded and stored as a sorted file or a database table keyed by prefix; a server lookup is then a single indexed read. Refresh it monthly with a scheduled job, and log how often the browser-side check returned null so you notice when the client path is silently degraded.
Writing the Breach Message
“Password is compromised” sounds like an accusation and invites the reply “but I just made it up”. Explain the mechanism in plain words — the password has appeared in a breach of some site, so attackers try it — and tell the user what to do next. Avoid showing the breach count (“seen 3,861,493 times”): it is striking but it tempts users to pick a password with a “low” count, which is still breached. Keep the message identical on client and server so a server-side rejection reads the same way, following the approach in keeping client and server error messages in sync.
Frequently Asked Questions
Is sending part of a password hash to a breach API safe?
The k-anonymity model sends only the first five hex characters of the SHA-1 hash. Hundreds of unrelated hashes share each prefix, and the comparison happens locally, so neither the password nor its full hash is disclosed. Routing through your own proxy also hides the user's IP from the third party.
What should happen if the breach service is unavailable?
Fail open on the client: leave the field valid and let the user continue. Repeat the check on the server against a local copy of the breach corpus, where an outage of the public API cannot block sign-ups.
Why hash with SHA-1 when it is considered broken?
The range API is indexed by SHA-1, and collision resistance is irrelevant here because the hash is only used as a lookup key, never to store or verify the password. Store passwords with a slow hash such as argon2id or bcrypt.
Should I show how many times a password was breached?
No. A count invites users to choose a password with a smaller number, which is still breached. State that the password appeared in a breach and ask for a different one.
Related Guides
- Password Validation Patterns — where breach screening sits among the other password rules.
- Implementing Async Email Availability Checks — the same debounced async pattern for another field.
- Throttling vs Debouncing Server Validation — choosing the timing strategy.
- Why Client-Side Validation Is Not Security — why the server must repeat this check.