Security
How to block breached passwords at signup and password change
Follow NIST SP 800-63B guidance to reject breached passwords at signup and reset, with UX tips, Node and Python code, and advice on failing open or closed.

Most password rules annoy users without making accounts much safer. Checking new passwords against breach data is the exception: it stops the exact passwords attackers try first, and it asks nothing of users who already pick good ones. This guide covers what NIST recommends, how to design the user experience, working code for Node and Python, and what to do when the check itself fails.
What NIST SP 800-63B recommends
NIST Special Publication 800-63B is the US government's digital identity guideline, and it has become the reference point for modern password policy well beyond government. Its core advice on memorized secrets is refreshingly practical:
- Check new passwords against a blocklist. When a user sets or changes a password, compare it against values known to be commonly used, expected or compromised, including passwords from previous breach corpuses. If it matches, reject it and tell the user why.
- Favor length over complexity. Set a sensible minimum length and allow long passwords and passphrases. Do not impose composition rules like "one uppercase, one number, one symbol."
- Do not force periodic rotation. Require a change only when there is evidence of compromise.
- Allow paste and password managers. Anything that discourages long, random passwords works against you.
The breach check is the piece that replaces composition rules. Instead of guessing which passwords are weak based on character classes, you check whether a password is actually known to attackers.
Where to run the check
Run the check every time a password is chosen:
- Signup, when the account is created.
- Password change, when a signed-in user picks a new password.
- Password reset, when a user sets a new password after a reset link.
You can also check at login. A login check does not block the user, but it can trigger a prompt to change a password that has since appeared in a breach. That is a softer flow, covered in the credential stuffing post.
Designing the user experience
A rejected password is a moment of friction, so the message matters.
Say what happened and why
"This password has appeared in a data breach, so attackers are likely to try it. Please choose a different one." This is clear, honest and specific. Avoid vague errors like "Password is too weak," which send users hunting for a missing symbol.
Do not shame or alarm
Many people reuse a password they have used for years. The message should not imply that their accounts are hacked. You are protecting the new account, not diagnosing a problem.
Check on submit, not on every keystroke
Checking each keystroke burns credits and sends partial passwords to your backend for no benefit. Validate on form submission or when the field loses focus, from your server.
Keep the form state
Do not clear the email or other fields when the password is rejected. Only the password field needs to change.
Suggest a better approach
A short hint helps: "Try a passphrase of several unrelated words, or use a password manager." It points users toward length without reintroducing complexity rules.
The API call
Finowl's password check is a single request. The password is hashed with SHA-1 and only a 5-character prefix is looked up against Finowl Intelligence, using a k-anonymity range search with padding. The password is never logged, stored or returned. If you want the details, read how password breach checks use k-anonymity.
curl -X POST https://api.finowl.co/v1/breaches/password \
-H "Authorization: Bearer $FINOWL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "password": "Summer2024!" }'The response is either { "breached": true, "count": 133211 } or { "breached": false, "count": 0 }.
Node.js and Express example
This example adds a breach check to a signup route. It uses the built-in fetch in Node 18 or later and a short timeout so the check cannot hang the request.
import express from "express";
const app = express();
app.use(express.json());
async function isBreachedPassword(password) {
const res = await fetch("https://api.finowl.co/v1/breaches/password", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FINOWL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error("password check failed");
err.code = body.error?.code ?? `http_${res.status}`;
throw err;
}
const data = await res.json();
return data.breached;
}
app.post("/signup", async (req, res) => {
const { email, password } = req.body;
if (typeof password !== "string" || password.length < 8) {
return res.status(400).json({ error: "Use at least 8 characters." });
}
try {
if (await isBreachedPassword(password)) {
return res.status(400).json({
error:
"This password has appeared in a data breach. Please choose a different one.",
});
}
} catch (err) {
// Fail open: log the error code (never the password) and continue.
console.warn("breach check unavailable:", err.code ?? err.name);
}
// ...hash the password with a slow algorithm and create the account
return res.status(201).json({ ok: true });
});
app.listen(3000);Note that the error log records only an error code. Never log the request body or the password, and check that your error tracking tool scrubs password fields.
Python example
The same logic in Python with requests, suitable for Flask, Django or FastAPI handlers.
import logging
import os
import requests
log = logging.getLogger(__name__)
FINOWL_URL = "https://api.finowl.co/v1/breaches/password"
class BreachCheckUnavailable(Exception):
pass
def is_breached_password(password: str) -> bool:
try:
resp = requests.post(
FINOWL_URL,
headers={
"Authorization": f"Bearer {os.environ['FINOWL_API_KEY']}",
"Content-Type": "application/json",
},
json={"password": password},
timeout=3,
)
except requests.RequestException as exc:
raise BreachCheckUnavailable(type(exc).__name__) from None
if resp.status_code != 200:
try:
code = resp.json()["error"]["code"]
except Exception:
code = f"http_{resp.status_code}"
raise BreachCheckUnavailable(code)
return resp.json()["breached"]
def validate_new_password(password: str) -> str | None:
"""Return an error message, or None if the password is acceptable."""
if len(password) < 8:
return "Use at least 8 characters."
try:
if is_breached_password(password):
return (
"This password has appeared in a data breach. "
"Please choose a different one."
)
except BreachCheckUnavailable as exc:
log.warning("breach check unavailable: %s", exc) # fail open
return NoneFailing open versus failing closed
Any external check can be unavailable. You need to decide in advance what happens when it is.
Failing open
If the check errors or times out, accept the password and continue. Users are never blocked by an outage, and you lose the protection only for the few passwords chosen during that window. For most consumer signups, this is the right default. You can also flag those accounts and re-prompt for a password check at next login.
Failing closed
If the check fails, reject the password change and ask the user to try again. This guarantees every stored password was checked, at the cost of blocking signups during an outage. It can make sense for admin accounts, financial actions, or internal tools where a short delay is acceptable.
Handling specific errors
Different failures deserve different handling:
| Response | Meaning | Suggested action |
|---|---|---|
429 rate_limited |
Over 60 requests per minute | Wait for Retry-After, or fail open |
429 quota_exceeded |
Out of credits | Fail open and alert your team |
5xx or 504 |
Lookup may have run | Do not loop; fail open or closed by policy |
400 invalid_input |
Bad request, such as over 4096 characters | Fix the input; not charged |
Avoid retry loops inside a signup request. A user is waiting, and repeated 5xx retries can be charged. The retries guide and errors reference cover each case.
Turning on auto top-ups with a saved card is a simple way to avoid quota_exceeded in production.
Cost planning
Each password check costs one credit. With 100 free credits a month, a small app can run signup checks at no cost. As volume grows, prepaid packs start at 10,000 credits for $29 and never expire. See pricing for all packs, and the password checks at signup guide for a step-by-step integration.
Frequently asked questions
Does NIST require checking passwords against breach lists?
NIST SP 800-63B directs verifiers to compare new passwords against a blocklist that includes passwords from previous breaches and to reject matches. It is one of the guideline's central password recommendations.
Should I reject a password that was seen only once?
Generally yes. A single appearance still means the password is in a dataset attackers can use, and rejecting it costs the user only a moment.
Should I fail open or fail closed if the check is unavailable?
Most consumer apps fail open so outages never block signups, then re-check later. Fail closed for high-value accounts where a short delay is acceptable.
Do I still need composition rules if I check for breached passwords?
No. NIST recommends against composition rules. A minimum length plus a breach check gives better protection with less friction.
Is the password stored when I call the API?
No. Finowl hashes the password, looks up only a 5-character hash prefix, and never logs, stores or returns the password.


