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

snippets/check-a-password-against-a-policy/n0.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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

snippets/check-a-password-against-a-policy/n0.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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.

  1. 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

  2. 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.

  3. 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

  4. 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

  5. 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.

  6. 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

  1. RecommendedN0The SP 800-63B rules, and the breach list under k-anonymityFree · Fast
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

Where the figures come from

Metadata