Skip to content

Password Breach Checker — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Breach Checker - 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 process.
//
// Language: C# 12 / .NET 8 (standard library only)
// Source:   CosmoDev polyglot showcase port of the Breach Checker tool,
//           ported from src/lib/breach-checker.ts (the canonical TypeScript
//           implementation).
// License:  display source - part of CosmoDev's polyglot tool pages.
//
// API: https://api.pwnedpasswords.com/range/{PREFIX} - free, no key. Returns
// one "SUFFIX:COUNT" line per hash sharing the prefix (~800 candidates); the
// suffix match happens locally. The TS reference takes an injectable fetch
// function so tests can stub the network; this port takes an optional
// HttpClient for the same reason (inject a shared client for connection
// reuse in production code).

using System.Security.Cryptography;
using System.Text;

/// <summary>Outcome of one Pwned Passwords k-anonymity lookup.</summary>
/// <param name="Breached">True when the exact hash suffix appeared in the API's candidate list.</param>
/// <param name="Count">How many times the password appeared in breaches. 0 = never seen. -1 = lookup failed.</param>
/// <param name="HashPrefix">First 5 chars of the uppercase SHA-1 hex - the only part sent to the API.</param>
/// <param name="HashSuffix">Remaining 35 chars of the hash, matched locally against the response.</param>
/// <param name="Error">Set when the lookup failed (network error or non-success response).</param>
/// <param name="Candidates">How many candidate suffixes the API returned (all checked locally).</param>
public sealed record BreachResult(
    bool Breached,
    int Count,
    string HashPrefix,
    string HashSuffix,
    string? Error = null,
    int? Candidates = null);

public static class BreachChecker
{
    public const string HibpRangeUrl = "https://api.pwnedpasswords.com/range/";

    /// <summary>SHA-1 of a UTF-8 string as uppercase hex (the format HIBP expects).</summary>
    public static string Sha1Hex(string input) =>
        Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(input)));

    /// <summary>Split a 40-char uppercase hash into the 5-char k-anonymity prefix and the 35-char suffix.</summary>
    public static (string Prefix, string Suffix) SplitHash(string hash)
    {
        string h = (hash ?? string.Empty).ToUpperInvariant();
        return (h[..5], h[5..]);
    }

    /// <summary>
    /// 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.
    /// </summary>
    public static int ParseRangeBody(string body, string suffix)
    {
        if (body is null || suffix.Length == 0) return 0;
        foreach (string line in body.Split('\n', '\r'))
        {
            int idx = line.IndexOf(':');
            if (idx == -1) continue;
            if (line[..idx].Trim() == suffix)
            {
                string raw = line[(idx + 1)..].Trim();
                return int.TryParse(raw, out int count) && count >= 0 ? count : 0;
            }
        }
        return 0;
    }

    /// <summary>Count the "SUFFIX:COUNT" candidate lines in a range response.</summary>
    public static int CountCandidates(string body)
    {
        if (body is null) return 0;
        int n = 0;
        foreach (string line in body.Split('\n', '\r'))
        {
            int idx = line.IndexOf(':');
            if (idx != -1 && line[..idx].Trim().Length > 0) n++;
        }
        return n;
    }

    /// <summary>
    /// Check a password against the Pwned Passwords corpus using k-anonymity.
    /// Only the prefix is sent over the network; the suffix match is local.
    /// Never throws - a failed lookup returns Count = -1 with Error set.
    /// </summary>
    public static async Task<BreachResult> CheckBreachAsync(
        string password, HttpClient? http = null, CancellationToken ct = default)
    {
        var (prefix, suffix) = SplitHash(Sha1Hex(password));
        BreachResult failure = new(Breached: false, Count: -1, HashPrefix: prefix, HashSuffix: suffix);

        HttpClient client = http ?? new HttpClient();
        try
        {
            using HttpResponseMessage response = await client.GetAsync(HibpRangeUrl + prefix, ct);
            if (!response.IsSuccessStatusCode)
            {
                return failure with
                {
                    Error = $"The breach database returned HTTP {(int)response.StatusCode}",
                };
            }
            string body = await response.Content.ReadAsStringAsync(ct);
            int count = ParseRangeBody(body, suffix);
            return new BreachResult(
                Breached: count > 0,
                Count: count,
                HashPrefix: prefix,
                HashSuffix: suffix,
                Error: null,
                Candidates: CountCandidates(body));
        }
        catch (Exception e)
        {
            // The TS reference distinguishes Error instances from anything
            // else; every .NET exception carries a Message, so it is used
            // directly.
            return failure with { Error = e.Message };
        }
        finally
        {
            if (http is null) client.Dispose();
        }
    }
}

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 →