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.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
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.
| Feature | What it answers | Cost and access (October 2026) |
|---|---|---|
| Email search (website) | Which known breaches included this address | Free, no account needed |
| Notifications | Alert when a verified address appears in a new breach | Free, needs email verification |
| Pwned Passwords | Has this password appeared in breach data | Free, no API key for the range endpoint |
| Email breach API | Same as the website search, from code | Paid API key (a third-party test reports HTTP 401 without one) |
| Domain search | Breached addresses on a domain you control | Free 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
requestslibrary, 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:
| Status | Meaning | What to do |
|---|---|---|
| 200 | The account appears in at least one breach | Parse the JSON body |
| 401 | Missing or invalid API key | Check the header name and the key value |
| 403 | No User-Agent header | Send a descriptive User-Agent |
| 404 | Account not found in any breach | Treat as a clean result |
| 429 | Rate limit exceeded | Wait for the Retry-After seconds, then retry |
| 503 | Service unavailable | Back 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:
- Change the exposed password on the breached site first, then on every other site where you used it.
- Generate a new unique password for each account with your password manager.
- Turn on a second factor everywhere that offers one, and prefer passkeys or security keys over SMS codes.
- Review recent logins and connected apps on your email and cloud accounts and remove anything you do not recognize.
- Watch for phishing that references the breached company, since attackers use leaked details to sound credible.
- 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 breach | Risk | First action |
|---|---|---|
| Email addresses only | Spam and phishing | Enable alerts, stay alert to lookalike mail |
| Passwords (any form) | Account takeover through reuse | Rotate everywhere the password was used |
| Password hints | Easier guessing of related passwords | Replace the hint and the password |
| Phone numbers | SMS phishing and SIM-swap attempts | Add a carrier PIN, drop SMS as a second factor |
| Physical addresses, dates of birth | Targeted fraud and social engineering | Watch financial accounts, consider a credit freeze |
| Government IDs, financial data | Identity theft | Freeze 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
| File | Needs API key | Needs network | Purpose |
|---|---|---|---|
| pwned_pw.py | No | Yes | Check one password |
| audit_csv.py | No | Yes | Bulk audit |
| hibp_email.py | Yes | Yes | Email breach lookups |
| domain_report.py | Yes, plus verified domain | Yes | Domain exposure report |
| watch_breaches.py | No | Yes | New-breach alerts |
| password_policy.py | No | Yes | Signup 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
echoand many file reads add\n, which produces a different hash and a false “not found.” Useprintf '%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
shredand 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-Afterheader 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.
- 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. - HTTP 403 Forbidden. The request had no User-Agent. Add one in
headers, or pass-Ato curl. - 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.
- 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. - 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.
- Every suffix comparison fails on Windows. The response uses CRLF line endings. Call
splitlines()in Python ortr -d '\r'in the shell. - Domain verification stays pending. DNS changes need time to propagate. Run
dig +short TXT yourdomain.comagainst a public resolver, confirm the exact value, and remove duplicate TXT records created during earlier attempts. - 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.
- SSL: CERTIFICATE_VERIFY_FAILED. The machine has an outdated CA bundle or sits behind a corporate proxy that re-signs traffic. Update
certifiwithpip install --upgrade certifiand setREQUESTS_CA_BUNDLEto your proxy’s CA file if you have one. - 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. - watch_breaches.py alerts on everything every run. The state file is not persisting. Set
HIBP_STATEto an absolute path the cron user can write to. - 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.





