Skip to content

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 →