Length rules and symbol rules do nothing about a password that is already in a credential-stuffing list. This asks whether it is - and the password never leaves the machine.
It is a FUNCTION, not a component, and it lives in @enigmax/utils for a reason: the same
rule has to hold on the server. A field can only refuse a leaked password in the browser,
where anyone can skip it, so the check that decides belongs in the route that writes the
account - and the field’s own check is the courtesy that tells the visitor early. In the
field it is one prop, breach, on <Input type="password"> - see Has it
leaked?.
import { checkPasswordBreach } from "@enigmax/utils";
const { breached, count } = await checkPasswordBreach(password);
// breached: true, count: 3730471
Installation
Adds the package and prints the import to use. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.
enigma add password-breachInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.
1 Install the dependencies
2 Use it
Import from @enigmax/utils.
No dependencies and no key: the service is free and open. It does need HTTPS, because hashing uses WebCrypto, which browsers only expose in a secure context (localhost counts).
In a form
Pass it to the field and let the form decide what it means.
<Input
type="password"
autoComplete="new-password"
breach={checkPasswordBreach}
onBreachChange={(state) => setBreached(state.status === "breached")}
/>
The check is debounced and aborted per keystroke, so an answer about a password three characters old can never land on the current one. Range responses are cached for five minutes, so typing costs one request per distinct prefix rather than one per keystroke.
Failure is not “safe”
A failed check throws. Rounding it down to “no breach found” is the one outcome that must not happen quietly, so the decision is yours:
try {
const { breached } = await checkPasswordBreach(password);
if (breached) return "This password has appeared in a breach.";
} catch (error) {
// Fail OPEN on a sign-up: an outage at a third party must not stop people registering.
// On a password CHANGE for a privileged account, failing closed may be the right call.
report(error);
}
An abort stays an AbortError, so a component cancelling on the next keystroke never
surfaces a network error. PasswordBreachError.reason is "insecure-context",
"network" or "service".
| Option | Default | |
|---|---|---|
endpoint |
Have I Been Pwned | Point it at your own proxy |
padding |
true |
Ask for decoy entries |
cache |
shared, 5 min | Pass your own, or null to disable |
fetch |
globalThis.fetch |
Route it through your own stack, or a test |
signal |
Cancel it |
Run the same check on the server when the form is submitted. A client-side check is a convenience, never a guarantee.
Why the password is safe
The range API is k-anonymous. The password is hashed with SHA-1 in the browser, the first five hex characters of that hash are sent, and the service returns every suffix it knows starting with those five - typically several hundred. The match happens locally.
password -> 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
sent -> GET /range/5BAA6
compared locally -> 1E4C9B93F3F0682250B6CF8331B7EE68FD8
The service sees five characters shared by hundreds of thousands of passwords. It cannot know which one was looked up, and never sees the password or its full hash. SHA-1 is not protecting anything here - it is the index the corpus is published under.
Add-Padding: true is sent by default, which pads the response with decoy entries. Without
it the response size narrows down which prefix was asked for, the one thing the model
otherwise leaks. Decoys carry a count of 0 and are indistinguishable from a real line
except by that zero, so matching on the suffix alone would report every padded response as
a breach.