Password Breach Checker — TypeScript source
Check if a password has appeared in known data breaches using k-anonymity. Only the first 5 characters of the SHA-1 hash are sent - your full password never leaves your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Password breach lookup via Have I Been Pwned's Pwned Passwords API, using
// k-anonymity: only the first 5 characters of the SHA-1 hash ever leave the
// browser. This module is the unit-test surface for the Breach Checker tool
// and is mirrored by cli/breach-checker (Go twin).
//
// API: https://api.pwnedpasswords.com/range/{PREFIX} — free, no key, CORS-
// enabled. Returns one "SUFFIX:COUNT" line per hash sharing the prefix
// (~800 candidates). The suffix match happens locally.
export interface BreachResult {
/** True when the exact hash suffix appeared in the API's candidate list. */
breached: boolean;
/** How many times the password appeared in breaches. 0 = never seen. -1 = lookup failed. */
count: number;
/** First 5 chars of the uppercase SHA-1 hex — the only part sent to the API. */
hashPrefix: string;
/** Remaining 35 chars of the hash, matched locally against the response. */
hashSuffix: string;
/** Set when the lookup failed (network error or non-200 response). */
error?: string;
/** How many candidate suffixes the API returned (all checked locally). */
candidates?: number;
}
export const HIBP_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
function getSubtle(): SubtleCrypto {
const subtle = globalThis.crypto?.subtle;
if (!subtle) {
throw new Error('Web Crypto (crypto.subtle) is not available in this environment');
}
return subtle;
}
function toUpperHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0');
return s.toUpperCase();
}
/** SHA-1 of a UTF-8 string as uppercase hex (the format HIBP expects). */
export async function sha1Hex(input: string): Promise<string> {
const digest = await getSubtle().digest('SHA-1', new TextEncoder().encode(input));
return toUpperHex(new Uint8Array(digest));
}
/** Split a 40-char uppercase hash into the 5-char k-anonymity prefix and the 35-char suffix. */
export function splitHash(hash: string): { prefix: string; suffix: string } {
const h = String(hash).toUpperCase();
return { prefix: h.slice(0, 5), suffix: h.slice(5) };
}
/**
* Search an HIBP range response for a hash suffix and return its breach count.
* Never throws; returns 0 when the suffix is not present. Tolerates LF and
* CRLF line endings, blank lines, and leading/trailing whitespace per line.
*/
export function parseRangeBody(body: string, suffix: string): number {
if (typeof body !== 'string' || !suffix) return 0;
for (const line of body.split(/\r?\n/)) {
const idx = line.indexOf(':');
if (idx === -1) continue;
if (line.slice(0, idx).trim() === suffix) {
const count = Number.parseInt(line.slice(idx + 1).trim(), 10);
return Number.isNaN(count) || count < 0 ? 0 : count;
}
}
return 0;
}
/** Count the "SUFFIX:COUNT" candidate lines in a range response. */
export function countCandidates(body: string): number {
if (typeof body !== 'string') return 0;
let n = 0;
for (const line of body.split(/\r?\n/)) {
if (line.indexOf(':') !== -1 && line.slice(0, line.indexOf(':')).trim() !== '') n++;
}
return n;
}
/**
* Check a password against the Pwned Passwords corpus using k-anonymity.
* Only `hashPrefix` is sent over the network; the suffix match is local.
* Never throws — a failed lookup returns { breached: false, count: -1, error }.
*/
export async function checkBreach(password: string, fetchFn: typeof fetch = fetch): Promise<BreachResult> {
const { prefix, suffix } = splitHash(await sha1Hex(password));
const base: BreachResult = { breached: false, count: -1, hashPrefix: prefix, hashSuffix: suffix };
let response: Response;
try {
response = await fetchFn(`${HIBP_RANGE_URL}${prefix}`);
} catch (e) {
return { ...base, error: e instanceof Error ? e.message : 'Network request failed' };
}
if (!response.ok) {
return { ...base, error: `The breach database returned HTTP ${response.status}` };
}
const body = await response.text();
const count = parseRangeBody(body, suffix);
return {
breached: count > 0,
count,
hashPrefix: prefix,
hashSuffix: suffix,
candidates: countCandidates(body),
};
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →