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.
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. Append --copy to write the source into your project instead.
enigma add password-breachInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
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.