Independent Application Developers Network

Our Blog

Return to Blog

Checking for Compromised Passwords with the Pwned Passwords API

By Steve Wood - August 12, 2026

If you collect passwords in a web application, it's worth checking them against Troy Hunt's [Pwned Passwords](https://haveibeenpwned.com/Passwords) database — a list of hundreds of millions of passwords exposed in real-world data breaches. If a user's chosen password shows up there, it's a bad password no matter how "strong" it looks, because it's already in every credential-stuffing wordlist attackers use.

The good news: you never send the actual password (or even its full hash) to the API. Here's how the check works, and how to implement it in both **Xbasic (Alpha Anywhere)** and **Python**.

How the API Works (k-Anonymity Model)

  1. Take a SHA-1 hash of the plaintext password.
  2. Send only the **first 5 characters** of that hash to the API: `GET https://api.pwnedpasswords.com/range/{first5}`
  3. The API returns every known compromised hash suffix that starts with those 5 characters — typically several hundred — each with a count of how many times it's appeared in breaches.
  4. You compare the **remaining 35 characters** of your local hash against that list. If there's a match, the password is compromised.

Because you only ever transmit a 5-character prefix, the API never sees enough of the hash to reconstruct the original password, and Have I Been Pwned has no way of knowing which specific password you checked.

Xbasic (Alpha Anywhere) Implementation

Compute the SHA-1 hash client-side (via JavaScript), send only the first 5 characters to the range endpoint, and search for the remaining 35 characters in the response body:

xbasic
  • hash_newPassword = upper(e.dataSubmitted.hash_newPassword)
  • hash_newPassword35 = right(hash_newPassword,35)
  • url = "https://api.pwnedpasswords.com/range/" + left(hash_newPassword,5)
  • result = http_get(url)
  • hashlist = result.body
if AT(hash_newPassword35,hashlist) > 0 ' found
    err_msg = "This password is not allowed because it was found in a list of known compromised passwords."
    goto endofscript
end if

A couple of implementation notes worth calling out for anyone adapting this:

  • **`upper()` matters.** The API returns hashes in uppercase hex, so make sure whatever computed `hash_newPassword` client-side is also uppercase before comparing.
  • **`AT()` does a substring search**, not a line-by-line match. Since each returned hash suffix is a fixed 35 characters, a substring match is safe here — you won't get a false positive from a coincidental match spanning two lines.
  • If you'd rather hash server-side instead of trusting a client-supplied hash, Xbasic doesn't have a built-in SHA-1 function, so you'd need to shell out or use a UDF/DLL. Hashing in JavaScript before submit (as you're doing) is the simpler path.
  • Consider wrapping the `http_get()` call in error handling — if the API is unreachable, you probably want to let the password through rather than block registration entirely (fail open on availability, fail closed on validation).

Suggested Alpha Anywhere panel

Checking for Compromised Passwords with the Pwned Passwords API

Python Implementation

```python
import hashlib
import requests

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

    response = requests.get(
        f"https://api.pwnedpasswords.com/range/{prefix}",
        headers={"Add-Padding": "true"},  # optional: mitigates response-size timing attacks
        timeout=5,
    )
    response.raise_for_status()

    for line in response.text.splitlines():
        candidate_suffix, count = line.split(":")
        if candidate_suffix == suffix:
            return True  # found in breach corpus

    return False


if __name__ == "__main__":
    pwd = "password123"
    if is_pwned(pwd):
        print("This password has appeared in a data breach — choose another.")
    else:
        print("Password not found in known breaches.")
```

A few notes:

  • `hashlib.sha1` gives you the hash in one line; no external dependencies beyond `requests`.
  • The API's `Add-Padding: true` header returns a randomized number of extra fake entries, which helps defend against attackers trying to infer the *real* breach count for a given prefix based on response size. It doesn't affect your matching logic.
  • The `count` value returned alongside each hash (`candidate_suffix, count = line.split(":")`) tells you how many times that password has been seen in breaches — you can log or surface that number if you want to give users more context than a flat reject.
  • Same fail-open/fail-closed consideration applies: decide up front what happens if `requests.get()` times out or errors.

Wrapping Up

Both implementations follow the same three steps — hash, truncate, compare — and neither ever exposes the plaintext password or a reversible hash over the wire. It's a small addition to a registration or password-change flow, and it closes off one of the more common ways accounts get compromised: reusing a password that's already sitting in an attacker's wordlist.