Check a password against a policy
Decide whether a password chosen at sign-up can be accepted, without letting it leave your infrastructure.
No AI needed
FreeFastYour data goes to a third party
The verdict
N0, because the standard sets out rules rather than judgements, and because the one requirement that needs outside data is satisfied without sending the data: five characters of the SHA-1 go out, the service returns eight hundred suffixes, and the comparison happens at your end. This entry’s test reads the address that was requested and checks that neither the password, nor its full digest, nor even its suffix appears in it. The rest of the code is mostly what it does not do: no composition rules, because the standard forbids them and because they produce “Motdepasse1!”, which the breach list catches and no composition rule does.
The SP 800-63B rules, and the breach list under k-anonymity
No AI needed
Rule and classic algorithmN0FreeFast
Python
"""
Check a password against the policy the standard actually asks for.
Rung N0. NIST SP 800-63B-4 is short and its requirements are rules, not
judgements: fifteen characters for a password used on its own, at least
sixty-four permitted, no composition rules, no periodic change, and a
comparison against a blocklist of passwords already known to be compromised.
Four of those five are arithmetic on a string.
The fifth is the only one that leaves the machine, and it leaves almost
nothing. The Have I Been Pwned range service takes the first five characters of
the SHA-1 of the password and returns every suffix that begins with them — some
eight hundred of them — so the password is never sent, the service cannot tell
which of the eight hundred was asked about, and the answer is a count. That is
what `pwned_count` below does, and it is worth reading: the whole privacy
argument is in two lines.
What this file deliberately does not do is count capitals, digits and symbols.
The standard forbids it — « Verifiers and CSPs SHALL NOT impose other
composition rules » — because those rules push people towards « Motdepasse1! »,
which the blocklist catches and no composition rule does.
"""
from __future__ import annotations
import hashlib
import unicodedata
# SP 800-63B-4, section 3.1.1.2: fifteen characters for a password used as a
# single factor, eight when it is only one factor among several.
MINIMUM = 15
MINIMUM_WITH_SECOND_FACTOR = 8
# The standard says « SHOULD permit a maximum password length of at least 64
# characters »: sixty-four is a floor on the ceiling, not the ceiling. Taking
# it as one turns down a seventy-character passphrase — six words out of a
# password manager — which is a good password refused by a rule of shape, in
# an entry whose whole thesis is that rules of shape push people towards bad
# ones. Two hundred and fifty-six is the usual ceiling; it exists to bound the
# hashing, not to judge anything.
MAXIMUM = 256
RANGE_URL = "https://api.pwnedpasswords.com/range/"
# Reasons that block. A blocklist that did not answer is reported and never
# decided: refusing every sign-up because a third party is down is an outage
# you did not choose, and accepting in silence is the check the standard puts
# in a SHALL, skipped without a word. The caller decides; `reasons` is what it
# decides on.
BLOCKING = ("not-text", "too-short", "too-long", "context-word", "breached")
def pwned_count(password: str, fetch=None) -> int:
"""
How many breaches this password appears in, without sending the password.
Only the first five characters of its SHA-1 leave: the service answers with
every suffix sharing that prefix, and the comparison happens here. The
`Add-Padding` header makes every answer the same size, so the length of the
response says nothing either.
"""
if fetch is None: # pragma: no cover - needs a network
def fetch(url: str) -> str:
import urllib.request
request = urllib.request.Request(url, headers={"Add-Padding": "true"})
with urllib.request.urlopen(request, timeout=5) as response:
return response.read().decode("utf-8")
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
for line in fetch(RANGE_URL + digest[:5]).splitlines():
suffix, _, count = line.strip().partition(":")
if suffix == digest[5:]:
return int(count)
return 0
def check_password(password, *, second_factor: bool = False, context=(), fetch=None) -> dict:
"""
Say whether this password may be accepted, and why not when it may not.
`context` is the short list of words a password must not be — the site's
name, the account's local part — which the standard allows a blocklist to
carry. It is compared after normalisation and without case, and nothing
else about the password's shape is judged.
`fetch` is injected by the tests; in production it is an HTTPS request.
It runs on a sign-up path, so a service that does not answer must not raise
here: the failure comes back as `blocklist-unavailable` in `reasons`, with
`breaches` at None, and `acceptable` says what the rest of the check says.
Only `BLOCKING` reasons make a password unacceptable.
"""
if not isinstance(password, str):
return _report(False, ["not-text"], None, 0)
# The standard asks for NFC, so that a password typed with a composed « é »
# and one typed with « e » plus an accent are the same password.
normalised = unicodedata.normalize("NFC", password)
length = len(normalised)
reasons = []
floor = MINIMUM_WITH_SECOND_FACTOR if second_factor else MINIMUM
if length < floor:
reasons.append("too-short")
if length > MAXIMUM:
reasons.append("too-long")
# Lower case rather than case folding, so that the two languages of this
# page compare the same way: folding turns « ß » into « ss », lowering
# leaves it alone, and JavaScript has no folding.
folded = normalised.lower()
if any(folded == unicodedata.normalize("NFC", word).lower() for word in context):
reasons.append("context-word")
# The blocklist is asked last, and only for a password that could still be
# accepted: a request that will change nothing is a request not to make.
breaches = None
if not reasons:
try:
breaches = pwned_count(normalised, fetch)
except Exception: # noqa: BLE001 - see BLOCKING: an outage is not a verdict
reasons.append("blocklist-unavailable")
else:
if breaches:
reasons.append("breached")
blocking = [reason for reason in reasons if reason in BLOCKING]
return _report(not blocking, reasons, breaches, length)
def _report(acceptable: bool, reasons: list, breaches: int | None, length: int) -> dict:
return {"acceptable": acceptable, "reasons": reasons, "breaches": breaches, "length": length}JavaScript
/**
* Check a password against the policy the standard actually asks for.
*
* Rung N0. NIST SP 800-63B-4 is short and its requirements are rules, not
* judgements: fifteen characters for a password used on its own, at least
* sixty-four permitted, no composition rules, no periodic change, and a
* comparison against a blocklist of passwords already known to be
* compromised. Four of those five are arithmetic on a string.
*
* The fifth is the only one that leaves the machine, and it leaves almost
* nothing. The Have I Been Pwned range service takes the first five characters
* of the SHA-1 of the password and returns every suffix that begins with them
* — some eight hundred of them — so the password is never sent, the service
* cannot tell which of the eight hundred was asked about, and the answer is a
* count. That is what `pwnedCount` below does, and it is worth reading: the
* whole privacy argument is in two lines.
*
* What this file deliberately does not do is count capitals, digits and
* symbols. The standard forbids it — « Verifiers and CSPs SHALL NOT impose
* other composition rules » — because those rules push people towards
* « Motdepasse1! », which the blocklist catches and no composition rule does.
*/
import { createHash } from 'node:crypto';
// SP 800-63B-4, section 3.1.1.2: fifteen characters for a password used as a
// single factor, eight when it is only one factor among several.
export const MINIMUM = 15;
export const MINIMUM_WITH_SECOND_FACTOR = 8;
// The standard says « SHOULD permit a maximum password length of at least 64
// characters »: sixty-four is a floor on the ceiling, not the ceiling. Taking
// it as one turns down a seventy-character passphrase — six words out of a
// password manager — which is a good password refused by a rule of shape, in
// an entry whose whole thesis is that rules of shape push people towards bad
// ones. Two hundred and fifty-six is the usual ceiling; it exists to bound the
// hashing, not to judge anything.
export const MAXIMUM = 256;
export const RANGE_URL = 'https://api.pwnedpasswords.com/range/';
// Reasons that block. A blocklist that did not answer is reported and never
// decided: refusing every sign-up because a third party is down is an outage
// you did not choose, and accepting in silence is the check the standard puts
// in a SHALL, skipped without a word. The caller decides; `reasons` is what it
// decides on.
export const BLOCKING = ['not-text', 'too-short', 'too-long', 'context-word', 'breached'];
/**
* How many breaches this password appears in, without sending the password.
*
* Only the first five characters of its SHA-1 leave: the service answers with
* every suffix sharing that prefix, and the comparison happens here. The
* `Add-Padding` header makes every answer the same size, so the length of the
* response says nothing either.
*/
export async function pwnedCount(password, fetchRange) {
const get = fetchRange ?? (async (url) => {
/* c8 ignore next 3 -- needs a network */
const response = await fetch(url, { headers: { 'Add-Padding': 'true' } });
return response.text();
});
const digest = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
const body = await get(RANGE_URL + digest.slice(0, 5));
for (const line of body.split(/\r?\n/)) {
const [suffix, count] = line.trim().split(':');
if (suffix === digest.slice(5)) return Number(count);
}
return 0;
}
/**
* Say whether this password may be accepted, and why not when it may not.
*
* `context` is the short list of words a password must not be — the site's
* name, the account's local part — which the standard allows a blocklist to
* carry. It is compared after normalisation and without case, and nothing else
* about the password's shape is judged.
*
* `fetchRange` is injected by the tests; in production it is an HTTPS request.
* It runs on a sign-up path, so a service that does not answer must not throw
* here: the failure comes back as `blocklist-unavailable` in `reasons`, with
* `breaches` at null, and `acceptable` says what the rest of the check says.
* Only `BLOCKING` reasons make a password unacceptable.
*
* @param {string} password
* @param {{secondFactor?: boolean, context?: string[], fetchRange?: Function}} [options]
*/
export async function checkPassword(password, { secondFactor = false, context = [], fetchRange } = {}) {
if (typeof password !== 'string') return report(false, ['not-text'], null, 0);
// The standard asks for NFC, so that a password typed with a composed « é »
// and one typed with « e » plus an accent are the same password.
const normalised = password.normalize('NFC');
const length = [...normalised].length;
const reasons = [];
const floor = secondFactor ? MINIMUM_WITH_SECOND_FACTOR : MINIMUM;
if (length < floor) reasons.push('too-short');
if (length > MAXIMUM) reasons.push('too-long');
// Lower case rather than case folding, so that the two languages of this
// page compare the same way: folding turns « ß » into « ss », lowering
// leaves it alone, and JavaScript has no folding.
const folded = normalised.toLowerCase();
if (context.some((word) => word.normalize('NFC').toLowerCase() === folded)) {
reasons.push('context-word');
}
// The blocklist is asked last, and only for a password that could still be
// accepted: a request that will change nothing is a request not to make.
let breaches = null;
if (reasons.length === 0) {
try {
breaches = await pwnedCount(normalised, fetchRange);
if (breaches) reasons.push('breached');
} catch {
// See BLOCKING: an outage is not a verdict.
reasons.push('blocklist-unavailable');
}
}
const blocking = reasons.filter((reason) => BLOCKING.includes(reason));
return report(blocking.length === 0, reasons, breaches, length);
}
const report = (acceptable, reasons, breaches, length) => ({
acceptable, reasons, breaches, length,
});Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
The breach list is a local double, and the counts shown are its own. What matters here is elsewhere: the address requested, shown under each case, holds only five characters of the hash.
A French passphrase
Given
le chat dort sur le radiateur
What it returns
{ "acceptable": true, "reasons": [], "breaches": 0, "length": 29 }
What it concludes
Accepted
What left the machine: https://api.pwnedpasswords.com/range/409F0
The password composition rules produce
Given
Motdepasse1!
What it returns
{ "acceptable": false, "reasons": [ "too-short" ], "breaches": null, "length": 12 }
What it concludes
Refusé — too short
Nothing left the machine: the refusal was settled before the list.
Sixteen identical letters, no capital, no digit
Given
aaaaaaaaaaaaaaaa
What it returns
{ "acceptable": false, "reasons": [ "breached" ], "breaches": 3, "length": 16 }
What it concludes
Refusé — found in a breach
What left the machine: https://api.pwnedpasswords.com/range/3499C
A famous passphrase
Given
correct horse battery staple
What it returns
{ "acceptable": false, "reasons": [ "breached" ], "breaches": 120, "length": 28 }
What it concludes
Refusé — found in a breach
What left the machine: https://api.pwnedpasswords.com/range/ABF7A
The site’s own name
Given
Boulangerie-Martin
What it returns
{ "acceptable": false, "reasons": [ "context-word" ], "breaches": null, "length": 18 }
What it concludes
Refusé — it is the site’s own name
Nothing left the machine: the refusal was settled before the list.
The account holder’s daughter’s name and birth year
Given
Clementine-2019-Martin
What it returns
{ "acceptable": true, "reasons": [], "breaches": 0, "length": 22 }
What it concludes
Accepted
What left the machine: https://api.pwnedpasswords.com/range/E6EA2
Here it breaksTwenty-two characters, no known breach: the policy accepts it, and it is the account holder’s daughter’s first name followed by her year of birth. The check only knows what has already leaked; what a relative would guess in three tries leaves no trace in any list. That is this level’s limit, and a model does not close it: a second factor does.
What this means for you
- Your data goes to a third party
- Always the same result
- Unit-testable, case by case
- Depends on an outside provider
- Negligible footprint
What this adds to your regulatory scope
- The password does not leave: only five characters of its SHA-1 digest are sent, and this entry’s test checks what goes out
- A third-party service holds the breach list: its availability gates this check, and the caller has to decide what to do when it does not answer
Breaking point
The check only knows what has already leaked: “Clementine-2019-Martin” — twenty-two characters, the account holder’s daughter’s first name and year of birth — is on no list, and passes. The witness is in the same test: “correct horse battery staple”, long enough as well, is refused because it has leaked.
When to move up a level
There is no level above this one, and that is the standard’s own conclusion: what a relative would guess in three tries leaves no trace in any breach list. The answer is a second factor, not one more rule about the password — and `context` compares whole words, not parts: “Boulangerie-Martin-2026” passes a list that carries “Boulangerie-Martin”.
The four options
- RecommendedN0The SP 800-63B rules, and the breach list under k-anonymityFree · Fast
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
The classic tool exists and is called zxcvbn: it estimates how many guesses a password would take by spotting patterns — dictionary words, dates, keyboard runs — and it is a useful thing to show while someone types. It is not on this ladder because it answers a different question: the standard asks for a decision, not a score, and its dictionaries are English ones in the Python port, which over-rates a French passphrase. It is also orthogonal to the requirement that does the work — the comparison against the breach list — which it does not replace.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
A self-hosted specialised model for judging a password would have to be trained on passwords, that is, on breaches, and it would carry their trace in its weights. It would also return a score where this check has to return a decision and its reason: a sign-up form has to tell someone why their choice was refused, and a number between zero and one is not a sentence you display.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Sending a user’s chosen password to a third-party provider is the one thing this entry exists to refuse: it is the secret itself, in the clear, out of your infrastructure and logged on somebody else’s, to fetch an opinion that four length comparisons and one k-anonymous request give exactly. No conceivable gain offsets the data leaving.
Further reading
- NIST SP 800-63B-4 — les exigences sur les mots de passe
- Have I Been Pwned — l’API des mots de passe, et le modèle de k-anonymat
- Cloudflare — l’explication du protocole de recherche par plage
- zxcvbn — l’estimateur de robustesse, et le portage maintenu en TypeScript avec ses paquets de langue
Where the figures come from
- NIST SP 800-63B-4 (consultée) : « SHALL require passwords […] to be a minimum of 15 characters in length », « SHOULD permit a maximum password length of at least 64 characters », « SHALL NOT impose other composition rules », « SHALL NOT require subscribers to change passwords periodically », « SHALL compare the prospective secret against a blocklist »
- Have I Been Pwned, API v3 (consultée) : « The Pwned Passwords API is freely accessible without the need for a subscription and API key » ; l’adresse prend les cinq premiers caractères du SHA-1, et l’en-tête Add-Padding rend toutes les réponses de taille comparable
- zxcvbn sur PyPI (consulté) : version 4.5.0, publiée le 19 février 2025, licence MIT, dictionnaires anglais ; le paquet npm du même nom date de 2017, et c’est le portage @zxcvbn-ts, version 4.2.0 du 12 août 2026, qui publie des paquets de langue dont le français
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.