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.

k-anonymity breach check The password is normalised and hashed locally, the five-character prefix is sent to a same-origin proxy, the response suffix list is scanned locally and the result sets the field's custom validity. Normalise value.normalize( "NFKC") Hash locally SHA-1 via crypto.subtle Send prefix first 5 hex chars to /api/pwned-range Match suffix scan ~800 returned lines Set verdict setCustomValidity( hit ? msg : "")
Only a five-character prefix shared by hundreds of unrelated hashes ever leaves the page, and matching happens in the browser.

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
Requests per sign-up with and without gating A bar chart of the average number of breach-range requests per sign-up under three wiring strategies. Every keystroke 18 requests / sign-up Debounced 500 ms 3.4 requests / sign-up Debounced + length gate + cache 1.2 requests / sign-up
Gating on local rules plus a debounce cuts lookups to roughly one per sign-up; the prefix cache absorbs back-and-forth edits.

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.

Fail-open and fail-closed breach screening Two columns showing how the browser check fails open and the server check fails closed, with the consequences of each. Browser check fails open • timeout or 5xx returns null • field stays valid, status text cleared ✓ sign-up keeps working during outages ✗ a breached password can reach the server Server check fails closed • queries a local, periodically synced corpus • unknown result blocks the password change ✓ breached passwords never get stored ✗ requires storage for the hash corpus
The browser treats an unknown result as valid so sign-up never stalls; the server re-checks against a local corpus that cannot time out.

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.

← Back to Password Validation Patterns