Engineering

🛡️ Check Passwords Against Breaches Without Leaking Them: k-Anonymity & the HIBP API

By Ateeq Y Tanoli · 29 July 2026 · 8 min read · 1,337 words

Rejecting weak passwords at signup is table stakes. The harder, more valuable check is whether a password has already appeared in a real-world data breach — because a credential that is strong on paper is worthless if attackers already have it in a credential-stuffing wordlist. The obvious way to check is to send the password somewhere and ask. The problem is equally obvious: you should never transmit a user's plaintext password, or even its full hash, to a third party. k-anonymity resolves that tension, and this guide shows exactly how to implement it.

What is k-anonymity password checking? It is a technique that verifies whether a password appears in known data breaches without ever transmitting the full password or its complete hash. The client sends only the first five characters of a SHA-1 hash; the server returns every matching suffix, so the checked password can never be identified from the request alone.

Why breached-password screening matters

Password strength meters measure guessability in the abstract. Breach screening measures a different and more urgent risk: reuse. Once a password leaks from one service, it is added to enormous corpora that attackers replay against every other login form on the internet. This is credential stuffing, and it succeeds precisely because people reuse passwords that were strong until the day they were exposed.

Regulators and standards bodies now treat breach screening as a baseline control rather than a nice-to-have. The guidance is explicit:

“When processing requests to establish and change memorized secrets, verifiers SHALL compare the prospective secrets against a list that contains values known to be commonly-used, expected, or compromised.” — NIST Special Publication 800-63B, Digital Identity Guidelines

The OWASP Authentication Cheat Sheet echoes this, recommending that applications screen new passwords against a list of known-breached credentials at registration and at every password change. The largest freely available corpus is the Pwned Passwords dataset from Have I Been Pwned, which holds more than 850 million real-world compromised password hashes drawn from thousands of breaches. The question is how to query a dataset that large without either downloading gigabytes locally or leaking the password you are trying to protect.

How k-anonymity works, step by step

The Pwned Passwords range API solves both problems at once. Instead of asking “is this password breached?”, your application asks “give me every breached hash that starts with these five characters,” then finishes the comparison locally. Here is the full flow:

  1. Compute the SHA-1 hash of the password and uppercase it. (SHA-1 is used here purely as a lookup key against the corpus, not for storage — you still store passwords with a slow algorithm like Argon2id.)
  2. Split the 40-character hash into a 5-character prefix and a 35-character suffix.
  3. Send only the prefix to https://api.pwnedpasswords.com/range/{prefix}.
  4. The API returns roughly 300–900 suffixes that share that prefix, each with a breach count.
  5. Search that response locally for your suffix. A match means the password is compromised; the number tells you how many times it has been seen.

Because a single five-character prefix maps to hundreds of distinct hashes, the server cannot know which one you were interested in. That anonymity set — the “k” in k-anonymity — is what keeps the query private.

What leaves your serverNaive approachk-anonymity approach
Plaintext passwordSometimesNever
Full password hashYesNever
Data transmittedFull 40-char hashFirst 5 chars only
Candidate hashes returned1 (an answer)~300–900 (an anonymity set)
Can the server identify the password?YesNo

Implementation in Node.js

The entire check is a hash, a slice, and one HTTPS request. No API key is required for the range endpoint. Add the recommended Add-Padding: true header so responses are padded to a uniform size, defeating traffic-analysis attacks that might otherwise infer results from payload length.

import crypto from "node:crypto";

export async function pwnedCount(password) {
  const hash = crypto.createHash("sha1")
    .update(password).digest("hex").toUpperCase();
  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);

  const res = await fetch(
    `https://api.pwnedpasswords.com/range/${prefix}`,
    { headers: { "Add-Padding": "true", "User-Agent": "rpt-breach-check" } }
  );
  if (!res.ok) throw new Error(`HIBP ${res.status}`);

  for (const line of (await res.text()).split("\n")) {
    const [candidate, count] = line.trim().split(":");
    if (candidate === suffix) return Number(count);
  }
  return 0; // 0 = not found in any known breach
}

// Reject compromised passwords at signup / password change:
if (await pwnedCount(candidate) > 0) {
  throw new Error("This password has appeared in a data breach. Choose another.");
}

Implementation in Python

import hashlib, requests

def pwned_count(password: str) -> int:
    sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]

    r = requests.get(
        f"https://api.pwnedpasswords.com/range/{prefix}",
        headers={"Add-Padding": "true", "User-Agent": "rpt-breach-check"},
        timeout=5,
    )
    r.raise_for_status()

    for line in r.text.splitlines():
        candidate, _, count = line.partition(":")
        if candidate == suffix:
            return int(count)
    return 0

Implementation in Go

package breach

import (
    "bufio"
    "crypto/sha1"
    "fmt"
    "net/http"
    "strconv"
    "strings"
)

func PwnedCount(password string) (int, error) {
    sum := sha1.Sum([]byte(password))
    hash := strings.ToUpper(fmt.Sprintf("%x", sum))
    prefix, suffix := hash[:5], hash[5:]

    req, _ := http.NewRequest("GET",
        "https://api.pwnedpasswords.com/range/"+prefix, nil)
    req.Header.Set("Add-Padding", "true")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()

    scanner := bufio.NewScanner(resp.Body)
    for scanner.Scan() {
        parts := strings.SplitN(scanner.Text(), ":", 2)
        if strings.TrimSpace(parts[0]) == suffix {
            n, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
            return n, nil
        }
    }
    return 0, nil
}

Interpreting the breach count

The number the API returns beside a matching suffix is not decoration — it is the count of times that password has been observed across all ingested breaches, and it should drive policy. A password seen a handful of times is already circulating; a password seen millions of times, like 123456 or password, sits at the very top of every attacker's dictionary and will be tried within the first few guesses of any credential-stuffing run.

Most teams treat any non-zero count as a hard rejection, which is the safest default and aligns with the NIST guidance quoted above. If you need a softer rollout — for example, screening existing users without forcing an immediate reset — you can tier the response: block outright above a threshold, warn and encourage a change in the middle band, and silently log low counts for later review. Whatever thresholds you choose, apply them consistently and communicate clearly. A vague “password not allowed” error frustrates users; telling them the password has appeared in a public breach and inviting them to pick a fresh one turns a blocked action into a teachable security moment.

Breach countRiskSuggested action
0Not in known corpusAllow
1–100Exposed, lower volumeReject at signup; warn existing users
> 100Widely circulatedReject unconditionally

Production considerations

Reference implementations are the easy part. Shipping this safely to real users means handling the failure modes:

Where this fits in a real defence stack

Breach screening answers one narrow question — “is this exact password already burned?” — and it answers it well. But it is not a substitute for generating strong, unique credentials in the first place. The most reliable way to guarantee a password has never been breached is to generate a fresh random one per site and never reuse it. A dedicated manager such as NordPass creates and stores those unique passwords with zero-knowledge encryption, so a leak at one service can never cascade into another. For monitoring whether your accounts have surfaced in a breach after the fact, endpoint suites like Kaspersky bundle continuous breach and identity monitoring alongside malware protection.

Layered together, the picture is clear: generate unique passwords, store them with a slow hash, screen every new password against the breach corpus using k-anonymity, and pair it all with multi-factor authentication. Each layer covers a different failure mode. k-anonymity is the one that lets you consult a database of 850 million compromised secrets without ever whispering your users' passwords to anyone.

Generate a Free Strong Password →

More Password Security Tools

🔑 SecureKeyGen⚔️ TitanPasswords🛡️ Best Password Generator🔐 Free Strong Password⚡ Instant Password🗝️ Iron Vault Keys👨‍👩‍👧‍👦 Safe Pass Builder🛡️ Trusty Password⚙️ StrongPassFactory🔑 SecureKeyGen.org📚 TrustyPassword.org
We use cookies to improve your experience. Learn more