Password breach

Ask Have I Been Pwned whether a password has leaked, without sending the password anywhere.

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

Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.

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.