Breach check

Ask whether a password has already leaked, without sending it anywhere. A function, not a component - so the same rule holds on the server.

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-breach

Installs 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.

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.