Password Policy and Breached-Password Screening
Your password policy should be short: a length rule, a blocklist, and nothing else. NIST SP 800-63B Revision 4 says verifiers shall not impose composition rules or periodic changes, and shall reject passwords found on a blocklist of commonly used, expected, or compromised values. (NIST SP 800-63B-4, Sec. 3.1.1)
The policy to implement
| Rule | Implementation |
|---|---|
| Minimum length | 15 characters when the password is the only factor; 8 when it is always paired with MFA (NIST) |
| Maximum length | Accept at least 64 characters |
| Characters | Accept all printable ASCII, spaces, and Unicode; normalize before hashing |
| Composition rules | None |
| Scheduled expiry | None; force a change only on evidence of compromise |
| Blocklist | Breached passwords, common passwords, and context words (your brand, the username) |
| Hints and security questions | None |
| Paste and password managers | Always allowed |
| Show password | Offer a toggle |
If you also fall under PCI DSS 4.0 for systems in the cardholder data environment, its requirement 8.3.6 sets a 12-character minimum. Organizations that go fully passwordless are no longer required to meet the password-specific requirements. (Cloud Security Alliance) Pick the strictest rule that applies and write down why.
Breached-password screening, step by step
The standard approach is the Have I Been Pwned Pwned Passwords range API, which uses k-anonymity so the password never leaves your server. (Troy Hunt)
- Compute the SHA-1 of the candidate password (only for this lookup; never store it).
- Send the first 5 hex characters to
https://api.pwnedpasswords.com/range/{prefix}, with theAdd-Padding: trueheader so response size does not leak information. - The API returns every hash suffix that shares the prefix, with a breach count.
- Compare suffixes locally. If yours appears, reject the password with a helpful message.
import hashlib, requests
def is_breached(password: str) -> bool:
h = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix, suffix = h[:5], h[5:]
r = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}",
headers={"Add-Padding": "true"}, timeout=2)
r.raise_for_status()
for line in r.text.splitlines():
s, count = line.split(":")
if s == suffix and int(count) > 0: # padded rows have count 0
return True
return False
For high volume or air-gapped environments, download the full dataset and host the range lookup yourself. Decide your failure mode in advance: if the lookup times out at signup, fail open with a background re-check rather than blocking signups.
When to check
- At signup and password change: reject breached passwords.
- At sign-in: check after successful verification. If breached, let the user in but require a change, ideally offering a passkey instead.
- Continuously: when new breach corpora are published, the only way to recheck stored hashes is at next sign-in. Do not try to crack your own users' hashes.
Failed-attempt limits
NIST requires limiting consecutive failed attempts with one authenticator on a single account to no more than 100. (NIST) In practice, use progressive delays per account and per IP, risk-based challenges, and notifications, instead of hard lockouts that attackers can use to deny service to real users (Chapter 13).
Error messages
Tell users what to do, not what rule they broke in red: "This password has appeared in a data breach. Choose a different one, or use a passkey instead."
Common mistakes
- Keeping complexity rules because an old audit checklist asked for them.
- Truncating long passwords silently.
- Sending full password hashes to any third-party API.
Key takeaways
- The policy is a length rule and a blocklist: 15 characters single-factor, 8 with MFA, no composition rules, no scheduled expiry.
- Screen passwords with the Pwned Passwords range API; the password never leaves your server.
- Check at signup, password change, and after each successful sign-in.
- Use progressive delays, not hard lockouts, and stay well under NIST's ceiling of 100 failures.