hibp
Have-I-Been-Pwned k-anonymity client. Only the first 5 characters of
SHA-1(password) ever leave your process — HIBP returns every hash with
that prefix and the client checks the remaining bytes locally.
Zero-dep beyond the base package — uses Node 22’s global fetch.
Basic use
import { createHibpClient } from '@exortek/password/hibp';
const hibp = createHibpClient({ userAgent: 'my-app/1.0' });
const check = await hibp.check(candidate, { failOpen: true });
if (check.pwned) {
return badRequest(`this password appears in ${check.count} known breaches`);
}Returns { pwned: boolean, count: number }. count is HIBP’s occurrence
tally — higher means “more widely reused across the breached corpora”,
i.e., more dangerous.
Options
createHibpClient({
endpoint?: 'https://api.pwnedpasswords.com/range/', // override for mirrors
timeoutMs?: 5000,
userAgent?: '@exortek/password', // set your app name in prod
fetch?: typeof fetch, // inject for tests
}): { check(password, options?) }check options
check(password, {
failOpen?: false, // default — throw HIBP_UNAVAILABLE on network error
}): Promise<{ pwned: boolean, count: number }>failOpen: true swallows network / HTTP errors and resolves to
{ pwned: false, count: 0 }. Use this on signup flows where
availability matters more than perfect blocking. Never fail-open on
password reset — those should hard-fail rather than silently accept
a possibly-breached password.
Set a distinctive userAgent in production. HIBP’s abuse
guidelines require a
descriptive UA so they can reach you if you accidentally flood their
rate-limit. @exortek/password/<version> is the default and works
for testing, but yours should be recognisable in their logs.
Recipe: signup gate
import { createHibpClient } from '@exortek/password/hibp';
const hibp = createHibpClient({ userAgent: 'my-app/1.0' });
app.post('/auth/signup', async (req, res) => {
const check = await hibp.check(req.body.password, { failOpen: true });
if (check.pwned) {
return res.status(400).json({
error: 'breached_password',
message: `this password appears in ${check.count.toLocaleString()} known breaches`,
});
}
// ... continue signup ...
});Recipe: change-password gate
app.post('/auth/password', async (req, res) => {
const check = await hibp.check(req.body.newPassword); // fail-closed
if (check.pwned) {
return res.status(400).json({ error: 'breached_password' });
}
// ... continue ...
});If HIBP is down here, the request fails with HIBP_UNAVAILABLE — safer
for a change-password path than silently accepting.
Errors
HIBP_UNAVAILABLE— network / HTTP failure, andfailOpenwas not set. Message includes the underlying reason.
Both malformed input and unrecognised responses simply return
{ pwned: false, count: 0 } rather than throwing.
What HIBP sees
Every request looks like:
GET https://api.pwnedpasswords.com/range/1E4C9
User-Agent: my-app/1.0
Add-Padding: trueThe Add-Padding: true header asks HIBP to interleave decoy entries so
even a passive observer of your TLS-decrypted traffic can’t estimate
your response count from the payload length. The k-anonymity design
already means only the first 5 characters of SHA-1(pw) are sent —
padding is defence in depth.
There are ~1M possible 5-character prefixes. On a cold cache, hitting HIBP for a specific prefix reveals nothing about which user typed which password.
Client-only alternative
If your deployment is air-gapped or has strict egress rules that
prohibit HIBP, ship a locally-hosted mirror (HIBP publishes torrent
downloads of the full corpus) and point endpoint at it. The
k-anonymity contract is identical.