Have I Been Pwned Setup: 13 Steps, 40 Min [2026]

Have I Been Pwned (HIBP) is a free breach-notification service run by Troy Hunt, and its password lookup is designed so the password itself never leaves your machine. Your client hashes it with SHA-1 locally and sends only the first five characters of the hash. Hunt has written about this anonymity model for the Pwned Passwords API, which lets a stranger’s server answer your question without learning your secret.

This tutorial takes you through 13 steps in about 40 minutes. You will search an email address, read the results, subscribe to alerts, test passwords by hand with curl, build Python tools for passwords and email breaches, verify a domain, and schedule a job that flags new breaches. By the end you will have a working toolkit you can drop into a repository or a small server.

Why bother in late October 2026? Breach data keeps flowing in. HIBP kept adding and updating breach records in 2026, and third-party catalog snapshots from October put the database at between 1,039 and 1,047 breached sites and roughly 17.8 billion accounts (counts vary by date and method, so treat them as approximate). Stolen logins feed credential stuffing, and a quick lookup tells you which of your accounts are exposed. For wider coverage beyond breach lookups, the dark web monitoring setup guide covers the paid options.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

What Have I Been Pwned Checks (and What It Cannot Tell You)

HIBP is a lookup service over breach data that researchers and the operator have collected and loaded. It does not scan the live internet, and it does not know whether an account is compromised right now. A hit means your address or password appeared in a dataset that was leaked at some point. A miss means it did not appear in any dataset HIBP holds, which is not the same as being safe.

The service has four parts that people often blur together. The email search tells you which breaches included an address. Notifications email you when a verified address shows up in a newly loaded breach. Pwned Passwords is a separate corpus that checks individual passwords, and it has nothing to do with whose account the password belonged to. Domain search lets an organization pull the breached addresses tied to a domain it can prove it controls.

The official FAQ draws the line between the first and third parts clearly. In its words, “Separately to the pwned address search feature, the Pwned Passwords service allows you to check if an individual password has previously been seen in a data breach.” That separation matters for the privacy design. An email lookup says “this person was in that breach.” A password lookup says only “this string exists somewhere in breach data,” with no link to any person.

Two more facts shape how you should use it. HIBP stays independent. A 2019 sale attempt was followed by a 2020 decision to keep the service independent, according to a recent explainer from Atomic VPN, and October 2026 reporting still ties it to Hunt and his company rather than a larger vendor. Also, HIBP is a breach lookup, not a data-broker removal tool. If your goal is to pull your details off people-search sites, the DeleteMe vs Incogni vs Optery comparison covers that separate problem.

FeatureWhat it answersCost and access (October 2026)
Email search (website)Which known breaches included this addressFree, no account needed
NotificationsAlert when a verified address appears in a new breachFree, needs email verification
Pwned PasswordsHas this password appeared in breach dataFree, no API key for the range endpoint
Email breach APISame as the website search, from codePaid API key (a third-party test reports HTTP 401 without one)
Domain searchBreached addresses on a domain you controlFree for domains with up to 10 breached addresses, ownership verification required, paid tiers for more

Prices for paid API tiers changed hands between third-party pages in October, and the figures disagreed, so this guide leaves them out. Check the live subscription page before you buy anything.

Prerequisites and Versions

You need very little to follow along. Everything runs on a laptop or a small Linux server, and the first four steps need only a browser.

  • A web browser and an email address you can receive mail on (Steps 1 to 3)
  • A Unix-like shell with bash 5.x, curl 8.x, and sha1sum from GNU coreutils (macOS users can use shasum -a 1)
  • Python 3.12 or newer and pip (Steps 6 to 12)
  • The requests library, installed in a virtual environment
  • jq for reading JSON on the command line
  • For Steps 8 to 11 only: a domain you control with access to its DNS records, plus a paid HIBP API key
  • A password manager that can export to CSV (any of them) for the audit in Step 7

Create the project folder and the virtual environment first. Every script later in the tutorial lives in this one directory.

mkdir -p ~/hibp-toolkit && cd ~/hibp-toolkit
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install "requests>=2.32" "pytest>=8"
python --version
curl --version | head -1
jq --version

Expected output on a current Ubuntu or macOS machine looks like this, with your own minor versions:

Python 3.12.x
curl 8.x.x (x86_64-pc-linux-gnu) libcurl/8.x.x OpenSSL/3.x.x
jq-1.7.x

One safety rule before you start. Never type a real password into any tool that is not yours, and never paste one into a chat window or a shared terminal log. The scripts below read passwords through a hidden prompt, and the audit script reads a file you delete when you finish.

Steps 1 to 3: Search, Read the Results, Subscribe

Step 1: Search your email address

Open haveibeenpwned.com in a browser, type your primary email address into the search box on the homepage, and submit. Start with the address you use most often, the one tied to your bank, your cloud storage, and your password manager account. The search is free and needs no sign-in.

You will land on one of two screens. A green banner means the address does not appear in any breach HIBP knows about. A red banner lists every breach that included it, newest and oldest mixed together, each with a logo, a date, and a short description. Repeat the search for every address you have used in the last ten years, including old school and work accounts, because forgotten accounts are where reused passwords hide.

Step 2: Read the breach results correctly

Every breach on the list has its own page, such as the Under Armour record. That page shows the breach date, when HIBP added it, how many accounts it holds, and the data classes that leaked. Data classes drive your response more than anything else, so read that line first.

An address-only leak is mostly a spam and phishing risk. A leak with passwords means you must change that password everywhere you reused it. A leak with phone numbers, physical addresses, or government IDs raises the odds of targeted phishing, SIM-swap attempts, and identity fraud. Table 3 later in this guide maps data classes to actions.

Check three more details on each page. Look at the breach date, because a ten-year-old leak with a password you have long since changed needs no action beyond confirming the change. Look at the “verified” and “sensitive” flags. HIBP hides sensitive breaches from public searches and shows them only after you confirm you own the address by email. Finally, check whether HIBP considers the data real. A fabricated or spam-list entry carries a label saying so, and you can usually relax about those.

The same pattern shows up in fresh incidents. When the Discord bot operator leak surfaced, the Double Counter breach affecting 28 million Discord accounts was the sort of record that lands in HIBP with a data-class list that tells you exactly what to rotate.

Sample result text you will see on the site for a hit looks like this:

Oh no, pwned!
Pwned on 3 breached sites and found no pastes

Breach: Example Forum        Date: 2019-04-11
Data classes: Email addresses, Passwords, Usernames

Your own result will differ. The point is the shape: breach name, date, and data classes. Write down each breach name and the data classes in a note. You will use that list in Step 13.

Step 3: Subscribe to notifications

A one-time search goes stale the day after you run it. Notifications fix that. On the homepage, open the Notify me link, enter the address, and complete the email verification step. After HIBP confirms you own the address, it sends an alert whenever that address turns up in a newly loaded breach.

Subscribe every address you care about, not just one. Use a mailbox you actually read, and add a mail filter so the alerts do not land in spam. Because the verification message carries a link, check that it came from the real HIBP domain before you click. An attacker who knows people use this service can send a lookalike.

If you manage a shared team mailbox, subscribe it only if the people who read it will act on alerts. A subscription nobody reads is worse than none, because it gives a false sense of coverage.

Steps 4 and 5: Pwned Passwords and the k-Anonymity Lookup by Hand

Step 4: Understand how the password check works

The Pwned Passwords website lets you type a password and see how many times it appeared in breach data. It does the hashing in your browser, then sends only a short hash prefix. You can use it for a quick sanity check, but do not type a password you care about into any web form if you can avoid it. The scripts in Steps 5 and 6 do the same job on your own machine.

The protocol is simple. You hash the password with SHA-1 and write the result in uppercase hex. You send the first five characters, called the prefix, to the range endpoint. The server returns every suffix it holds that starts with that prefix, along with how many times each suffix appeared. You compare the rest of your hash, the suffix, against that list on your side.

The server sees a five-character prefix that matches a large group of hashes, so it cannot tell which one you were checking. Troy Hunt described the idea in his own words: “When V2 of the service launched earlier this year, Cloudflare came up with a really neat anonymity model which meant you could query the API with a fragment of a hash of the password (just the first 5 chars) and get back enough info to establish whether the entire hash is in the database or not.” That was written years ago about the second version of the service, and the range design it describes is still what the API documentation shows today.

One caution from the current documentation: SHA-1 here is only a lookup key. It is not a recommendation to store passwords with SHA-1. Store passwords with a slow, salted algorithm such as Argon2id or bcrypt.

Step 5: Run the lookup by hand with curl

Do it once by hand so the Python code in Step 6 stops feeling like magic. This bash snippet hashes a test string, splits it into prefix and suffix, calls the range endpoint, and looks for the suffix in the answer.

PW='correct horse battery staple'
HASH=$(printf '%s' "$PW" | sha1sum | awk '{print toupper($1)}')
PREFIX=${HASH:0:5}
SUFFIX=${HASH:5}
echo "prefix=$PREFIX suffix=$SUFFIX"

curl -s -A 'hibp-toolkit-demo/1.0' -H 'Add-Padding: true' \
  "https://api.pwnedpasswords.com/range/$PREFIX" \
  | tr -d '\r' \
  | awk -F: -v s="$SUFFIX" '$1==s {print "FOUND count=" $2; found=1} END {if(!found) print "not found"}'

Use printf '%s' and never echo. Plain echo adds a trailing newline, which changes the hash and gives you a clean “not found” for a password that is actually in the corpus. That single character is the most common reason hand-rolled checks lie.

The Add-Padding: true header asks the API to pad responses with fake entries so an observer cannot guess the true prefix from the response size. Padded entries carry a count of zero, so the awk filter above needs no special handling beyond treating zero as not found. The tr -d '\r' call strips Windows-style line endings that would otherwise break the comparison.

A known test vector helps you confirm your pipeline works. The SHA-1 of the word password is 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, so the prefix is 5BAA6 and the suffix is 1E4C9B93F3F0682250B6CF8331B7EE68FD8. Run the same script with that word and you should get a hit with a large count. The exact count changes as HIBP loads new data, so do not hard-code it.

prefix=5BAA6 suffix=1E4C9B93F3F0682250B6CF8331B7EE68FD8
FOUND count=<a very large number that changes over time>

Run it once more with the passphrase above. A multi-word passphrase may or may not be in the corpus, and either outcome teaches you something: HIBP only knows passwords that appeared in breaches, so a missing entry never proves a password is strong.

Step 6: Build a Python Password Checker

Now wrap the same logic in a script you can reuse. This version hides your typing with getpass, retries politely when the API returns HTTP 429, and exits with a non-zero code on a hit so you can use it in shell pipelines.

#!/usr/bin/env python3
"""pwned_pw.py - check a password against Pwned Passwords with k-anonymity."""
import getpass
import hashlib
import sys
import time

import requests

API = "https://api.pwnedpasswords.com/range/"
UA = "hibp-toolkit/1.0 (+https://example.com/contact)"


def sha1_parts(password: str) -> tuple[str, str]:
    digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    return digest[:5], digest[5:]


def fetch_range(prefix: str, session: requests.Session, retries: int = 3) -> str:
    headers = {"User-Agent": UA, "Add-Padding": "true"}
    for _ in range(retries):
        r = session.get(API + prefix, headers=headers, timeout=10)
        if r.status_code == 200:
            return r.text
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "2")))
            continue
        r.raise_for_status()
    raise RuntimeError("rate limited after retries")


def pwned_count(password: str, session: requests.Session | None = None) -> int:
    prefix, suffix = sha1_parts(password)
    body = fetch_range(prefix, session or requests.Session())
    for line in body.splitlines():
        tail, _, count = line.partition(":")
        if tail == suffix:
            return int(count)
    return 0


def main() -> int:
    password = getpass.getpass("Password to check (hidden): ")
    count = pwned_count(password)
    if count:
        print(f"PWNED: seen {count:,} times in breach data. Do not use it.")
        return 1
    print("Not found in Pwned Passwords. That does not prove it is strong.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Run it and type a throwaway password when prompted. A typical session looks like this:

$ python pwned_pw.py
Password to check (hidden):
PWNED: seen <count> times in breach data. Do not use it.
$ echo $?
1

Three design choices deserve a note. The function returns an integer, so callers can set their own threshold. The User-Agent string identifies your tool, which is polite and, according to the API documentation, required. The padded zero-count entries need no special code, because a padded match simply returns 0, the same answer as no match at all.

Step 7: Audit a Password Manager Export

Checking one password at a time does not scale. Most password managers can export a CSV, and a bulk audit tells you which stored logins appear in breach data. This is the single most useful thing the toolkit does for a normal user.

Handle the export like a loaded weapon. It holds every password you own in plain text. Save it to an encrypted volume or a RAM disk, run the audit, and delete the file immediately afterward. Do not leave it in your Downloads folder, and do not sync it anywhere.

The script below reads the CSV, hashes each password locally, caches responses by prefix so repeated prefixes cost no extra requests, and prints only the site, the username, and the count. It never prints a password.

#!/usr/bin/env python3
"""audit_csv.py - audit a password manager CSV export without printing secrets."""
import csv
import sys

import requests

from pwned_pw import fetch_range, sha1_parts


def audit(path: str) -> int:
    cache: dict[str, str] = {}
    session = requests.Session()
    bad = 0
    with open(path, newline="", encoding="utf-8") as fh:
        for row in csv.DictReader(fh):
            password = row.get("password") or ""
            if not password:
                continue
            prefix, suffix = sha1_parts(password)
            if prefix not in cache:
                cache[prefix] = fetch_range(prefix, session)
            count = 0
            for line in cache[prefix].splitlines():
                tail, _, c = line.partition(":")
                if tail == suffix:
                    count = int(c)
                    break
            if count:
                bad += 1
                site = row.get("name") or row.get("url") or "?"
                print(f"{count:>12,}  {site}  ({row.get('username', '')})")
    print(f"\n{bad} stored password(s) found in breach data.")
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(audit(sys.argv[1]))

Column names differ by manager. Open the first line of your export and adjust the password, name, url, and username keys to match. Then run the script and wipe the file:

python audit_csv.py ~/secure/export.csv
shred -u ~/secure/export.csv   # on macOS use: rm -P ~/secure/export.csv

Sample output follows. The counts here are placeholders, since your results depend on your own data:

         <n>  oldforum.example  (jane.doe)
         <n>  shop.example      (jane.doe)

2 stored password(s) found in breach data.

Sort the findings by count and rotate the highest ones first. A password that appears many times is one attackers already try in credential stuffing runs. Then look for reuse inside the export itself. Any two sites sharing a password mean one breach can unlock both, even if neither password is in HIBP yet.

Steps 8 to 10: Verify Your Domain, Get an API Key, Pull Email Breaches

Step 8: Verify your domain

If you run a company domain, you can see which addresses on it appear in breaches. HIBP will not hand that list to just anyone. You must prove you control the domain. The FAQ states the rule plainly: “Multiple breached addresses can be retrieved by the domain search feature but only after successfully verifying that the person performing the search is authorised to access assets on the domain.”

Sign in on the HIBP dashboard with an email address you control, add your domain, and pick a verification method. Verification options include a DNS record, an email to a standard mailbox on the domain, or a file on the web server. Pick the DNS option if you can, because it needs no mailbox and no web changes. Add the TXT record HIBP shows you, wait for it to propagate, then click verify.

Check the record from your terminal before you click:

dig +short TXT example.com | grep -i have-i-been-pwned

For domains with up to 10 breached addresses, a recent security write-up reports that HIBP’s domain monitoring is free. Beyond that, paid tiers apply and the details live on the live subscription page. The access rules for larger domains can change, so confirm them there.

Step 9: Get an API key and test it

Scripted email lookups and domain pulls use the HIBP API v3, and the documentation asks for an hibp-api-key header on those calls. A third-party test reports that requests without a key return HTTP 401. Buy the smallest plan that fits your needs from the subscription page, and store the key in an environment variable, never in your code.

export HIBP_API_KEY='paste-your-key-here'
echo "HIBP_API_KEY=$HIBP_API_KEY" > ~/.hibp.env
chmod 600 ~/.hibp.env

Test the key with a single call. The 404 response is good news, because it means the account is not in any breach:

curl -s -o /dev/null -w '%{http_code}\n' \
  -A 'hibp-toolkit/1.0' \
  -H "hibp-api-key: $HIBP_API_KEY" \
  'https://haveibeenpwned.com/api/v3/breachedaccount/test%40example.com'

Interpret the status code with this table:

StatusMeaningWhat to do
200The account appears in at least one breachParse the JSON body
401Missing or invalid API keyCheck the header name and the key value
403No User-Agent headerSend a descriptive User-Agent
404Account not found in any breachTreat as a clean result
429Rate limit exceededWait for the Retry-After seconds, then retry
503Service unavailableBack off and try again later

Step 10: Pull email breaches from code

Now script the website search. This client reads the key from the environment, URL-encodes the address, and prints each breach name, date, and data classes. It treats 404 as a clean result and 429 as a signal to wait.

#!/usr/bin/env python3
"""hibp_email.py - list breaches for an address through the HIBP API v3."""
import os
import sys
import time
import urllib.parse

import requests

BASE = "https://haveibeenpwned.com/api/v3"
UA = "hibp-toolkit/1.0 (+https://example.com/contact)"


def headers() -> dict:
    key = os.environ.get("HIBP_API_KEY")
    if not key:
        sys.exit("Set HIBP_API_KEY first.")
    return {"hibp-api-key": key, "user-agent": UA}


def breaches_for(account: str, session: requests.Session) -> list[dict]:
    url = f"{BASE}/breachedaccount/{urllib.parse.quote(account)}"
    for _ in range(3):
        r = session.get(url, headers=headers(), params={"truncateResponse": "false"}, timeout=15)
        if r.status_code == 200:
            return r.json()
        if r.status_code == 404:
            return []
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "3")))
            continue
        r.raise_for_status()
    raise RuntimeError("rate limited after retries")


if __name__ == "__main__":
    s = requests.Session()
    for acct in sys.argv[1:]:
        found = breaches_for(acct, s)
        print(f"== {acct}: {len(found)} breach(es)")
        for b in found:
            print(f"  {b['BreachDate']}  {b['Name']:<20} {', '.join(b['DataClasses'])}")
        time.sleep(1.6)

The short sleep between accounts keeps you under the rate limit of an entry-level plan. Run the script against your own addresses only. Looking up other people’s addresses without their consent raises privacy problems, so stick to your own.

$ python hibp_email.py [email protected]
== [email protected]: 2 breach(es)
  2019-04-11  ExampleForum         Email addresses, Passwords, Usernames
  2021-08-02  ExampleShop          Email addresses, Phone numbers

Step 11: Script Domain Monitoring

With a verified domain and a key, you can ask HIBP for every breached address on that domain in one call. The response is a JSON object that maps each alias (the part before the @) to the list of breach names it appeared in. That is the raw material for an internal report.

Large third-party incidents make this report more useful. When a vendor leaks customer data, as in the ShinyHunters claims covered in the McKesson breach report, employee addresses used to sign up for that vendor’s services show up in HIBP and nowhere else in your logs. The domain report is how you find them.

#!/usr/bin/env python3
"""domain_report.py - dump breached aliases for a verified domain to CSV."""
import csv
import sys
import time

import requests

from hibp_email import BASE, headers


def domain_breaches(domain: str, session: requests.Session) -> dict[str, list[str]]:
    url = f"{BASE}/breacheddomain/{domain}"
    for _ in range(3):
        r = session.get(url, headers=headers(), timeout=30)
        if r.status_code == 200:
            return r.json()
        if r.status_code == 404:
            return {}
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "3")))
            continue
        r.raise_for_status()
    raise RuntimeError("rate limited after retries")


if __name__ == "__main__":
    domain = sys.argv[1]
    data = domain_breaches(domain, requests.Session())
    out = sys.argv[2] if len(sys.argv) > 2 else f"{domain}-breaches.csv"
    with open(out, "w", newline="", encoding="utf-8") as fh:
        w = csv.writer(fh)
        w.writerow(["address", "breach_count", "breaches"])
        for alias, names in sorted(data.items(), key=lambda kv: -len(kv[1])):
            w.writerow([f"{alias}@{domain}", len(names), "|".join(names)])
    print(f"{len(data)} breached address(es) written to {out}")

Run it against the domain you verified:

$ python domain_report.py example.com
2 breached address(es) written to example.com-breaches.csv
$ head -3 example.com-breaches.csv
address,breach_count,breaches
[email protected],2,ExampleForum|ExampleShop
[email protected],1,ExampleShop

Sort by breach count and contact the owners of the top rows first. Send them the breach names, not their passwords, since HIBP never gave you passwords in the first place. Ask each person to rotate any reused credentials and to enable a second factor.

If the script returns an empty object for a domain you know has exposure, check that the verification is still active and that the API key’s plan covers the number of breached addresses on your domain. Free monitoring applies only to domains with up to 10 breached addresses, so a larger domain needs a paid tier.

Step 12: Watch New Breaches on a Schedule

Notifications cover individual addresses. A scheduled job covers the catalog itself. This script polls the public breach list, remembers which breach names it has already seen, and prints (or posts) anything new. It needs no API key, because the catalog endpoint is public.

The first run seeds the state file and stays quiet. Every later run reports only the difference. That way you get one alert per new breach and never a flood on day one.

#!/usr/bin/env python3
"""watch_breaches.py - alert on newly added HIBP breaches."""
import json
import os
import pathlib
import sys

import requests

URL = "https://haveibeenpwned.com/api/v3/breaches"
UA = "hibp-toolkit/1.0 (+https://example.com/contact)"
STATE = pathlib.Path(os.environ.get("HIBP_STATE", "~/.hibp-seen.json")).expanduser()
WEBHOOK = os.environ.get("ALERT_WEBHOOK")


def main() -> int:
    r = requests.get(URL, headers={"user-agent": UA}, timeout=30)
    r.raise_for_status()
    catalog = {b["Name"]: b for b in r.json()}
    seen = set(json.loads(STATE.read_text())) if STATE.exists() else None

    if seen is None:
        STATE.write_text(json.dumps(sorted(catalog)))
        print(f"Seeded state with {len(catalog)} breaches.")
        return 0

    fresh = [catalog[n] for n in sorted(set(catalog) - seen)]
    for b in fresh:
        line = f"{b['AddedDate'][:10]} {b['Name']} ({b['PwnCount']:,} accounts): {', '.join(b['DataClasses'])}"
        print("NEW BREACH", line)
        if WEBHOOK:
            requests.post(WEBHOOK, json={"text": f"New HIBP breach: {line}"}, timeout=10)
    STATE.write_text(json.dumps(sorted(catalog)))
    return 0


if __name__ == "__main__":
    sys.exit(main())

Schedule it with cron. Hourly is more than enough, since new breaches arrive in bursts and not by the minute. Use absolute paths, because cron runs with a stripped-down environment.

crontab -e
# add this line:
17 * * * * cd /home/you/hibp-toolkit && HIBP_STATE=/home/you/.hibp-seen.json ALERT_WEBHOOK='https://hooks.example.com/xyz' /home/you/hibp-toolkit/.venv/bin/python watch_breaches.py >> /home/you/hibp-watch.log 2>&1

Test the job without waiting an hour. Delete the last entry from the state file with a text editor or jq, run the script, and watch it report that breach as new:

jq 'del(.[-1])' ~/.hibp-seen.json > /tmp/s.json && mv /tmp/s.json ~/.hibp-seen.json
.venv/bin/python watch_breaches.py
NEW BREACH 2026-10-22 Utair (<count> accounts): Email addresses, Names, Phone numbers

The line above is an illustration of the format. Your run will print whichever breach you removed from the state file. If you also run login endpoints of your own, pair this feed with rate limits and lockouts. The guide on how to block API credential stuffing shows what to deploy once you know a new dump is circulating.

One more report from the week of October 19, 2026 claims that Hunt announced an AI-assisted intelligence layer for HIBP, first for API customers. That claim comes from a single third-party page and no official HIBP source confirmed it when this guide was written. Treat it as unconfirmed until it appears on HIBP’s own site.

Step 13: Block Pwned Passwords at Signup and Respond to Hits

The best use of the range API is at the moment someone chooses a password. If you run any login system, reject passwords that appear in breach data before you store them. Security guidance such as NIST’s digital identity recommendations tells defenders to screen new passwords against lists of known-compromised values, and Pwned Passwords is the most common source for that screen.

Add this helper to your signup or password-change handler. It fails open on network errors on purpose. A dependency outage should not stop every user from registering, so log the failure and let the request through.

# password_policy.py
import logging

import requests

from pwned_pw import pwned_count

log = logging.getLogger(__name__)
_session = requests.Session()


def reject_if_pwned(password: str, threshold: int = 1) -> None:
    """Raise ValueError when the password appears at least `threshold` times."""
    try:
        count = pwned_count(password, _session)
    except (requests.RequestException, RuntimeError) as exc:
        log.warning("pwned-passwords check skipped: %s", exc)
        return
    if count >= threshold:
        raise ValueError(
            "That password appears in known data breaches. Please choose a different one."
        )

Set threshold=1 for strict blocking. If you want a softer rollout, start with a warning at a high count and tighten it over a few weeks. Attackers rely on credential stuffing against sites that allow reused logins, and the credential stuffing prevention playbook for iGaming platforms shows how screening fits next to rate limiting and device checks.

A hit on your own account needs a response plan. Work through these steps in order:

  1. Change the exposed password on the breached site first, then on every other site where you used it.
  2. Generate a new unique password for each account with your password manager.
  3. Turn on a second factor everywhere that offers one, and prefer passkeys or security keys over SMS codes.
  4. Review recent logins and connected apps on your email and cloud accounts and remove anything you do not recognize.
  5. Watch for phishing that references the breached company, since attackers use leaked details to sound credible.
  6. If the data class includes phone numbers or government IDs, add a carrier PIN and consider a credit freeze.

Passkeys remove the password problem entirely, because there is nothing for a breach to leak. The WebAuthn passkeys setup guide walks through adding them to your own application. If a gaming or social account was already taken over, the 12-step Roblox account recovery walkthrough shows the recovery pattern that applies to most platforms.

Data class in the breachRiskFirst action
Email addresses onlySpam and phishingEnable alerts, stay alert to lookalike mail
Passwords (any form)Account takeover through reuseRotate everywhere the password was used
Password hintsEasier guessing of related passwordsReplace the hint and the password
Phone numbersSMS phishing and SIM-swap attemptsAdd a carrier PIN, drop SMS as a second factor
Physical addresses, dates of birthTargeted fraud and social engineeringWatch financial accounts, consider a credit freeze
Government IDs, financial dataIdentity theftFreeze credit, contact the issuer, report fraud

The Complete Project Layout

Put everything together in one directory. The layout below matches the scripts in this tutorial, so you can copy each block into the named file.

hibp-toolkit/
├── .venv/
├── requirements.txt
├── pwned_pw.py          # Step 6: single password check
├── audit_csv.py         # Step 7: bulk audit of a manager export
├── hibp_email.py        # Step 10: email breaches via API v3
├── domain_report.py     # Step 11: verified-domain report to CSV
├── watch_breaches.py    # Step 12: new-breach watcher for cron
├── password_policy.py   # Step 13: signup gate
└── tests/
    └── test_pwned.py

Pin your dependencies so a rebuild gives the same result:

printf 'requests>=2.32\npytest>=8\n' > requirements.txt
pip install -r requirements.txt

Tests protect you from the two mistakes that bite hardest: a wrong hash split and a broken policy gate. This file uses the public test vector for the word password and monkeypatches the network call so the tests run offline.

# tests/test_pwned.py
import pytest

import pwned_pw
from password_policy import reject_if_pwned


def test_split_matches_known_vector():
    prefix, suffix = pwned_pw.sha1_parts("password")
    assert prefix == "5BAA6"
    assert suffix == "1E4C9B93F3F0682250B6CF8331B7EE68FD8"


def test_trailing_newline_changes_hash():
    assert pwned_pw.sha1_parts("password") != pwned_pw.sha1_parts("password\n")


def test_count_found(monkeypatch):
    body = "AAAAA:0\n1E4C9B93F3F0682250B6CF8331B7EE68FD8:42\n"
    monkeypatch.setattr(pwned_pw, "fetch_range", lambda p, s, retries=3: body)
    assert pwned_pw.pwned_count("password") == 42


def test_count_not_found(monkeypatch):
    monkeypatch.setattr(pwned_pw, "fetch_range", lambda p, s, retries=3: "AAAAA:3\n")
    assert pwned_pw.pwned_count("password") == 0


def test_policy_blocks_pwned(monkeypatch):
    monkeypatch.setattr("password_policy.pwned_count", lambda pw, s: 7)
    with pytest.raises(ValueError):
        reject_if_pwned("password")


def test_policy_fails_open(monkeypatch):
    import requests

    def boom(pw, s):
        raise requests.ConnectionError("offline")

    monkeypatch.setattr("password_policy.pwned_count", boom)
    reject_if_pwned("anything")  # must not raise
$ python -m pytest -q
......                                                                   [100%]
6 passed in 0.12s
FileNeeds API keyNeeds networkPurpose
pwned_pw.pyNoYesCheck one password
audit_csv.pyNoYesBulk audit
hibp_email.pyYesYesEmail breach lookups
domain_report.pyYes, plus verified domainYesDomain exposure report
watch_breaches.pyNoYesNew-breach alerts
password_policy.pyNoYesSignup gate

Common Pitfalls

Most failed HIBP integrations break in the same handful of places. Read this list before you ship anything.

  • Hashing a string with a trailing newline. Shell echo and many file reads add \n, which produces a different hash and a false “not found.” Use printf '%s' in bash and .strip() only when you know the newline is not part of the password.
  • Reading “not found” as “safe.” HIBP only knows passwords that appeared in collected breach data. A fresh, weak password such as a pet name plus a year may never have been leaked and is still easy to guess.
  • Reading a hit as proof of a current compromise. An address in a ten-year-old breach does not mean someone is inside the account today. It means that credential should be treated as burned.
  • Leaving the CSV export on disk. The Step 7 export is a list of every secret you own. Delete it with shred and keep it off synced folders.
  • Forgetting the User-Agent header. Requests without one fail, and a generic library default may be rejected. Always send a descriptive string with a contact URL.
  • Hammering the API in a tight loop. A 429 response means slow down. Honor the Retry-After header and add a pause between accounts.
  • Sending the full password or full SHA-1 to a third-party “checker” site. Use the range endpoint or a tool you control. A well-built checker such as PhishDestroy’s pwned password tool shows the five-character prefix it sends. A clone site may not, so check what any tool transmits before you trust it with a real secret.
  • Subscribing one mailbox and never reading it. An alert that nobody sees protects nothing. If the account behind the alerts uses a weak login, the Steam Guard and Discord 2FA setup steps show how to lock down accounts that matter.

Troubleshooting

When something breaks, match the symptom below. Each fix is short because the causes are usually simple.

  1. HTTP 401 Unauthorized on email or domain calls. The key is missing, mistyped, or sent under the wrong header name. The header must be hibp-api-key. Print the first four characters of the variable to confirm it loaded, and make sure your shell exported it.
  2. HTTP 403 Forbidden. The request had no User-Agent. Add one in headers, or pass -A to curl.
  3. HTTP 404 on breachedaccount. This is the good outcome. The account is not in any breach. Treat it as an empty list, not as an error.
  4. HTTP 429 Too Many Requests. You exceeded the rate limit for your plan. Read Retry-After, sleep that long, and add a pause between accounts. If you hit this often, move to a larger plan or batch your work.
  5. Password you know is breached comes back “not found.” Check for a trailing newline, mixed case in the suffix, or Unicode differences. Make sure you hash UTF-8 bytes and compare uppercase hex to uppercase hex.
  6. Every suffix comparison fails on Windows. The response uses CRLF line endings. Call splitlines() in Python or tr -d '\r' in the shell.
  7. Domain verification stays pending. DNS changes need time to propagate. Run dig +short TXT yourdomain.com against a public resolver, confirm the exact value, and remove duplicate TXT records created during earlier attempts.
  8. Domain search returns nothing for a domain with known exposure. Confirm the verification is still active and the plan covers your domain size. Free monitoring applies to domains with up to 10 breached addresses only.
  9. SSL: CERTIFICATE_VERIFY_FAILED. The machine has an outdated CA bundle or sits behind a corporate proxy that re-signs traffic. Update certifi with pip install --upgrade certifi and set REQUESTS_CA_BUNDLE to your proxy’s CA file if you have one.
  10. The cron job never fires. Cron has a minimal PATH, so use absolute paths for Python and the script, redirect output to a log, and check grep CRON /var/log/syslog.
  11. watch_breaches.py alerts on everything every run. The state file is not persisting. Set HIBP_STATE to an absolute path the cron user can write to.
  12. Script hangs on a slow network. Every call above passes a timeout. If you removed it, restore it, and wrap the call so the job fails fast and retries on the next run.

Advanced Tips

Once the basics work, a few upgrades turn the toolkit into something you can run in production.

Cache range responses for a short time. A signup service that checks thousands of passwords will see the same prefixes repeatedly, and an in-memory cache with a one-hour expiry cuts API calls without weakening privacy, since the cache holds only public hash ranges. Pair it with a connection pool by reusing one requests.Session.

Choose your response mode. The Add-Padding header hides the true prefix from anyone watching response sizes, so keep it on in all production code. The API also supports an NTLM hash mode for Active Directory auditing, which you can request with a query parameter on the range endpoint. Use it only for internal checks against your directory’s own hashes.

Wire the policy gate into your pipeline. Run audit_csv.py on a schedule against an encrypted export held by a dedicated service account, and fail a CI job if any production credential in your secrets inventory appears in breach data. Do not store the export in the repository.

Combine passwords with second factors. A clean Pwned Passwords result still leaves a single factor between an attacker and the account. Game and social platforms make the habit easy, and the Fortnite 2FA setup is a short example of enabling app-based codes on a consumer account.

Track exposure over time. Store the output of domain_report.py with a date in a spreadsheet or a small database. A rising breach count for one domain often points at a single vendor or signup form that leaked your staff addresses, and the trend helps you decide which services to retire.

Consider the limits. HIBP reflects what has been collected and loaded. Fresh infostealer logs, private dumps, and unreleased leaks stay invisible until they surface. Use HIBP as one signal next to password hygiene, hardware-backed second factors, and endpoint protection (more hands-on guides sit in the cybersecurity section), and read Hunt’s weekly updates (Weekly Update 525 was titled “AI Data Breach Hyperbole”) for his view on which headline breaches are real.

FAQ

Is Have I Been Pwned safe to use?

Yes for the email search and the range password API as designed. HIBP stays independent and is run by Troy Hunt, and the password lookup sends only a five-character hash prefix. Avoid unofficial lookalike sites and never type a real password into a tool you do not trust.

Does Have I Been Pwned store my password?

The Pwned Passwords range lookup does not receive your password or your full hash. It receives only the prefix. The site holds hashes from public breach datasets, not the passwords you check against them.

What should I do if my email is pwned?

Open the breach page and read the data classes. If passwords leaked, change that password everywhere you reused it, turn on a second factor, and move to passkeys where possible. If only your email leaked, stay alert for phishing and subscribe to notifications.

Is the Have I Been Pwned API free?

No for the email and domain endpoints. A third-party test reports that email searches through the API need a paid key and return HTTP 401 without one. The Pwned Passwords range endpoint needs no key. Check the live subscription page for current prices.

Can I check a whole domain?

You can check a verified domain through domain search, which is free for domains with up to 10 breached addresses and paid beyond that. Ownership verification is required before HIBP shows any results.

Why does a strong password show up as pwned?

HIBP counts exact strings that appeared in breach data. If someone else chose the same phrase, or you reused it somewhere that leaked, it appears even though it looks complex. Strength and uniqueness are different properties, so generate a random password for every site.

How often does Have I Been Pwned add new breaches?

Regularly. HIBP keeps loading and updating records. Third-party snapshots in October 2026 counted between 1,039 and 1,047 breached sites. Use Step 12’s watcher or the notification service to learn about new ones without checking by hand.

Does a clean result mean my accounts are safe?

No. A clean result means the address or password is absent from the data HIBP holds. It cannot see private dumps, fresh stealer-malware logs, or breaches nobody has published yet. Keep strong unique passwords and a second factor on everything important.

Elias Virtanen

Elias Virtanen

Cybersecurity Analyst

Elias Virtanen is the Cybersecurity Analyst at Tech Insider, bringing hands-on expertise from his background in penetration testing and security consulting. He previously worked as a security researcher at F-Secure in Helsinki, where he focused on threat intelligence and vulnerability disclosure. Elias covers ransomware trends, zero-trust architecture, and the evolving regulatory landscape including NIS2 and the EU Cyber Resilience Act. He holds a CISSP certification and an MSc in Information Security from Aalto University.

View all articles