Functional Weave
Code in Python

auth.password-policy

Check a new password against a named policy (NIST SP 800-63B-4 by default): length, common passwords, name and email.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 31 tests, run in TypeScript, Python and Rust.passwordPolicy 8 · checkPassword 23

What it does

Decide whether a new password is acceptable, and say why not in words a form can show. The same function runs in the browser as the person types and on the server, which has the final say, so the two can never disagree:

policy = passwordPolicy("nist-800-63b-4-single-factor")
check  = checkPassword(password, email, name, policy)
# {valid: false, failures: [{code: "too_short", message: "Use at least 15 characters."}, ...]}

The functions

A group: 2 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. password_policy (name: string) -> PasswordPolicy
  2. check_password (password: string, email: string?, name: string?, policy: PasswordPolicy) -> PasswordCheck

The types it declares, generated into your project

@dataclass(frozen=True)
class PasswordPolicy:
    """The rules a new password has to meet."""

    name: str
    #: in characters (code points), 1 or more
    min_length: int
    #: in characters, at least minLength
    max_length: int
    #: 0 to 4 of: lower-case, capitals, digits, anything else
    min_character_classes: int
    #: refuse passwords on the common-password list
    block_common: bool
    #: refuse passwords containing the email's local part or a word of the name
    block_personal: bool

@dataclass(frozen=True)
class PasswordCheck:
    """Whether a password meets a policy, and every reason it does not."""

    valid: bool
    #: empty when valid
    failures: List[PasswordFailure]

@dataclass(frozen=True)
class PasswordFailure:
    """One broken rule."""

    code: PasswordFailureCode
    #: a sentence for the form, such as "Use at least 15 characters."
    message: str

PasswordFailureCode = Literal["too_short", "too_long", "too_few_character_classes", "too_common", "contains_email", "contains_name"]

Once installed, your code imports each one from the group's module.

password_policy throws on bad input 8 tests

def password_policy(name: str) -> PasswordPolicy
namestringnist-800-63b-4-single-factor, nist-800-63b-4-multi-factor or composition-12-3
returnsPasswordPolicy

For example

  • password_policy(nist-800-63b-4-single-factor) → name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true NIST SP 800-63B-4: a password that is the only factor needs 15 characters
  • password_policy(nist-800-63b-4-multi-factor) → name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true NIST SP 800-63B-4: a password used with a second factor needs 8
  • password_policy(composition-12-3) → name composition-12-3, min length 12, max length 128, min character classes 3, block common true, block personal true composition rules for organisations that still require them
from fune.auth.password_policy import password_policy  # auth.password-policy@^1
impl/python/password_policy.py · 20 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .auth_password_policy_data import POLICIES  ← this capability’s own data, compiled from data/policies.json into the same file by fune build
from .auth_password_policy_types import PasswordPolicy


def password_policy(name: str) -> PasswordPolicy:
    """A named policy from the data, so a browser and an API that both ask for
    "nist-800-63b-4-single-factor" enforce exactly the same numbers."""
    for row in POLICIES:
        if row.name == name:
            return PasswordPolicy(
                name=row.name,
                min_length=row.min_length,
                max_length=row.max_length,
                min_character_classes=row.min_character_classes,
                block_common=row.block_common,
                block_personal=row.block_personal,
            )
    raise ValueError(
        'unknown password policy "%s"; known policies: %s' % (name, ", ".join(row.name for row in POLICIES))
    )

check_password throws on bad input 23 tests

def check_password(password: str, email: Optional[str], name: Optional[str], policy: PasswordPolicy) -> PasswordCheck
passwordstringthe new password, exactly as typed
emailstring?the account's email, so its local part can be refused inside the password; null if unknown
namestring?the person's name, so its words can be refused inside the password; null if unknown
policyPasswordPolicyusually passwordPolicy("nist-800-63b-4-single-factor")
returnsPasswordCheckvalid, and every rule broken, in a fixed order, with a message to show beside the field

For example

  • check_password(correct horse battery staple, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block pe…) → valid true, failures a long passphrase with nothing personal in it
  • check_password(password, —, —, name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true) → valid false, failures ×1 password is on the common list
  • check_password(PassWord, —, —, name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true) → valid false, failures ×1 the common list is matched without regard to case
from fune.auth.password_policy import check_password  # auth.password-policy@^1
impl/python/check_password.py · 103 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List, Optional

from .auth_password_policy_data import COMMON_PASSWORDS  ← this capability’s own data, compiled from data/policies.json into the same file by fune build
from .auth_password_policy_types import PasswordCheck, PasswordFailure, PasswordPolicy

#: Words shorter than this in a name or email are not refused inside a
#: password: "al" or "jo" would refuse half of all passwords.
MIN_PERSONAL = 3

_COMMON = frozenset(row.password for row in COMMON_PASSWORDS)


def _ascii_lower(text: str) -> str:
    # str.lower() folds non-ASCII letters, differently from the other languages.
    return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in text)


def _is_ascii_alnum(ch: str) -> bool:
    return ("a" <= ch <= "z") or ("A" <= ch <= "Z") or ("0" <= ch <= "9")


def _words(text: str) -> List[str]:
    """Pieces between ASCII punctuation and spaces, lower-cased, 3+ characters."""
    out = []
    current = ""
    for ch in text:
        if ord(ch) < 128 and not _is_ascii_alnum(ch):
            if len(current) >= MIN_PERSONAL:
                out.append(_ascii_lower(current))
            current = ""
        else:
            current += ch
    if len(current) >= MIN_PERSONAL:
        out.append(_ascii_lower(current))
    return out


def _is_whole(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _check_policy(policy: PasswordPolicy) -> None:
    if not _is_whole(policy.min_length) or policy.min_length < 1:
        raise ValueError("policy minLength must be a whole number of at least 1")
    if not _is_whole(policy.max_length) or policy.max_length < policy.min_length:
        raise ValueError("policy maxLength must be a whole number no smaller than minLength")
    if not _is_whole(policy.min_character_classes) or not 0 <= policy.min_character_classes <= 4:
        raise ValueError("policy minCharacterClasses must be a whole number from 0 to 4")


def check_password(password: str, email: Optional[str], name: Optional[str], policy: PasswordPolicy) -> PasswordCheck:
    """Every rule of the policy the password breaks, in a fixed order, each with
    a sentence to show beside the field. The same function runs in the
    browser and on the server."""
    if not isinstance(password, str):
        raise TypeError("password must be a string")
    _check_policy(policy)
    failures = []
    length = len(password)  # code points, as TypeScript's Array.from counts them

    if length < policy.min_length:
        failures.append(PasswordFailure(code="too_short", message="Use at least %d characters." % policy.min_length))
    if length > policy.max_length:
        failures.append(PasswordFailure(code="too_long", message="Use no more than %d characters." % policy.max_length))
    if policy.min_character_classes > 0:
        lower = upper = digit = other = 0
        for ch in password:
            if "a" <= ch <= "z":
                lower = 1
            elif "A" <= ch <= "Z":
                upper = 1
            elif "0" <= ch <= "9":
                digit = 1
            else:
                other = 1
        if lower + upper + digit + other < policy.min_character_classes:
            failures.append(
                PasswordFailure(
                    code="too_few_character_classes",
                    message="Use at least %d of these: lower-case letters, capital letters, digits, symbols."
                    % policy.min_character_classes,
                )
            )

    folded = _ascii_lower(password)
    if policy.block_common and folded in _COMMON:
        failures.append(
            PasswordFailure(code="too_common", message="This password is too common. Choose something harder to guess.")
        )
    if policy.block_personal:
        if isinstance(email, str):
            at = email.rfind("@")
            local = email[:at] if at >= 0 else email
            candidates = _words(local)
            if len(local) >= MIN_PERSONAL:
                candidates.append(_ascii_lower(local))
            if any(word in folded for word in candidates):
                failures.append(
                    PasswordFailure(code="contains_email", message="Do not include your email address in your password.")
                )
        if isinstance(name, str) and any(word in folded for word in _words(name)):
            failures.append(PasswordFailure(code="contains_name", message="Do not include your name in your password."))
    return PasswordCheck(valid=len(failures) == 0, failures=failures)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add auth.password-policy

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add auth.password-policy --only passwordPolicy
Download for Python auth.password-policy-1.0.1-python.fune · 78,263 bytes sha256 a53efbb639874c7b7bfe8255f7432b446ef45b0dd715f1705c3de40d448ffda5

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./auth.password-policy-1.0.1-python.fune, or fetch it from a terminal with fune pull auth.password-policy@1.0.1:python.

The whole function, every language, is one file too: auth.password-policy-1.0.1.fune, 91,821 bytes, sha256 fc14f2bb4a0f992a8860e5ff7867dfd44257c67365449bd312570c296594f8d3. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before auth.password-policy.passwordPolicy
# fune: before auth.password-policy.checkPassword

after — your function gets the result and the arguments, and returns the final result.

# fune: after auth.password-policy.passwordPolicy
# fune: after auth.password-policy.checkPassword

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show auth.password-policy --steps.

# fune: step auth.password-policy.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

passwordPolicy 8 tests

CaseArgumentsExpected
NIST SP 800-63B-4: a password that is the only factor needs 15 characters nist-800-63b-4-single-factor → name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true
NIST SP 800-63B-4: a password used with a second factor needs 8 nist-800-63b-4-multi-factor → name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true
composition rules for organisations that still require them composition-12-3 → name composition-12-3, min length 12, max length 128, min character classes 3, block common true, block personal true
an unknown name strong → error: unknown password policy "strong"; known policies: nist-800-63b-4-single-factor, nist-800-63b-4-multi-factor, composition-12-3
names are exact: case matters NIST-800-63B-4-SINGLE-FACTOR → error: unknown password policy
names are exact: no trimming nist-800-63b-4-multi-factor → error: unknown password policy
the empty name → error: unknown password policy
an older revision's name is not silently mapped to the new one nist-800-63b → error: unknown password policy

checkPassword 23 tests

CaseArgumentsExpected
a long passphrase with nothing personal in it correct horse battery staple, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block pe… → valid true, failures
password is on the common list password, —, —, name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×1
the common list is matched without regard to case PassWord, —, —, name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×1
too short and too common, both reported password, —, —, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×2
too short, with no email or name to compare short, —, —, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×1
the empty password , —, —, name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×1
130 characters is over the maximum of 128 ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab, —, —, name nist-800-63b-4-multi-factor, min len… → valid false, failures ×1
exactly 128 characters is allowed abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab, —, —, name nist-800-63b-4-multi-factor, min lengt… → valid true, failures
the email's local part and the name, each reported adalovelace2026!!, ada.lovelace@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block pers… → valid false, failures ×2
a three-letter local part is refused inside the password my-bob-passphrase-is-long, bob@example.com, —, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×1
Show the other 13 tests
CaseArgumentsExpected
name words under three letters are not refused aljo-is-a-long-passphrase, —, Al Jo, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true → valid true, failures
eight emoji are 8 characters, not 16 UTF-16 units: too short for 15 🔑🔑🔑🔑🔑🔑🔑🔑, —, —, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common true, block personal true → valid false, failures ×1
eight emoji meet a minimum of 8 🔑🔑🔑🔑🔑🔑🔑🔑, —, —, name nist-800-63b-4-multi-factor, min length 8, max length 128, min character classes 0, block common true, block personal true → valid true, failures
one character class when three are required alllowercaseletters, —, —, name composition-12-3, min length 12, max length 128, min character classes 3, block common true, block personal true → valid false, failures ×1
lower-case, capitals and digits make three classes Lower1234567, —, —, name composition-12-3, min length 12, max length 128, min character classes 3, block common true, block personal true → valid true, failures
a non-ASCII letter counts in the fourth class, with symbols: lower-case, digit and ö make three passwördlong1, —, —, name composition-12-3, min length 12, max length 128, min character classes 3, block common true, block personal true → valid true, failures
every rule broken at once, in the fixed order password1, password@example.com, Word Smith, name composition-12-3, min length 12, max length 128, min character classes 3, block common true, block personal true → valid false, failures ×5
a policy that blocks neither common nor personal passwords password, password@example.com, Pass Word, name custom, min length 8, max length 64, min character classes 0, block common false, block personal false → valid true, failures
a minimum length of 0 x, —, —, name custom, min length 0, max length 64, min character classes 0, block common false, block personal false → error: policy minLength must be a whole number of at least 1
a maximum below the minimum x, —, —, name custom, min length 8, max length 7, min character classes 0, block common false, block personal false → error: policy maxLength must be a whole number no smaller than minLength
five character classes do not exist x, —, —, name custom, min length 8, max length 64, min character classes 5, block common false, block personal false → error: policy minCharacterClasses must be a whole number from 0 to 4
a fractional minimum length x, —, —, name custom, min length 8.5, max length 64, min character classes 0, block common false, block personal false → error: policy minLength must be a whole number of at least 1
a password that is not text 12,345,678, —, —, name custom, min length 8, max length 64, min character classes 0, block common false, block personal false → error: password must be a string

More from the author

It is a group because a policy is only useful to `checkPassword`; `passwordPolicy` looks one up by name from the data, so both sides of an application name the policy rather than copying its numbers.

## Policies (data/policies.json)

| name | min | max | classes | source | |---|---:|---:|---:|---| | `nist-800-63b-4-single-factor` | 15 | 128 | 0 | NIST SP 800-63B-4 §3.1.1.2: a password that is the only factor SHALL be at least 15 characters | | `nist-800-63b-4-multi-factor` | 8 | 128 | 0 | the same section: 8 when a second factor is also required | | `composition-12-3` | 12 | 128 | 3 | for organisations whose own rules still demand character classes |

All three check the common-password list and personal words. NIST SP 800-63B-4 (26 August 2025) says verifiers SHALL NOT impose composition rules and SHOULD permit at least 64 characters; the maximum here is 128, which bounds the work a hash does without refusing any real passphrase. Figures checked against https://pages.nist.gov/800-63-4/sp800-63b.html. A new revision will be a new policy name in a new version, never an edit of these.

A caller may also pass its own `PasswordPolicy` record; nonsense numbers (a minimum below 1, a maximum below the minimum, more than 4 classes) throw.

## Rules, in the order failures are reported

1. `too_short` / `too_long`: length in characters, meaning Unicode code points, as NIST specifies. An emoji is one character, not the two UTF-16 units JavaScript's `length` counts. 2. `too_few_character_classes`: lower-case a-z, capitals A-Z, digits 0-9, and everything else (symbols, spaces, and any non-ASCII letter). Only checked when the policy asks for classes. 3. `too_common`: the password, with A-Z folded to a-z, is on the list in `data/common-passwords.json` (exact match, not substring). 4. `contains_email`: the password contains the email's local part, or any piece of it between punctuation (`ada.lovelace@...` gives `ada.lovelace`, `ada` and `lovelace`), ignoring case. 5. `contains_name`: the password contains any word of the name, ignoring case. Pieces shorter than 3 characters are ignored in both, since refusing every password that contains "al" helps nobody.

Every broken rule is listed, not just the first, so a form can show them all at once. Messages are plain sentences meant for the person choosing the password; the codes are stable for code to branch on. Case folding is ASCII only, so every language folds identically.

## The common-password list

The 1,000 most common distinct passwords of 8 or more characters from the UK National Cyber Security Centre's list of the 100,000 most common passwords in Have I Been Pwned's breach corpus (NCSC, "Passwords, passwords everywhere", April 2019, `PwnedPasswordsTop100k.txt`), lower-cased and de-duplicated, with each entry's rank in that list (the last one is rank 2,902). ncsc.gov.uk was unavailable while this was built, so the file was taken from the verbatim mirror in SecLists (`Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt`). Shorter entries are left out because every policy here refuses them for length already. A 15-character minimum makes the list matter much less; that is the point of NIST's longer minimum. A full breached-password check (such as the Pwned Passwords range API) needs the network and belongs in the application, not here.

Sources: NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management, section 3.1.1.2 (https://pages.nist.gov/800-63-4/sp800-63b.html); NCSC, Top 100k passwords (https://www.ncsc.gov.uk/static-assets/documents/PwnedPasswordsTop100k.txt, mirrored at https://github.com/danielmiessler/SecLists).

## Notices

Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).

Source: NCSC, top 100,000 passwords from Have I Been Pwned's Pwned Passwords.

1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE262
README.md4,496
data/common-passwords.json42,286
data/policies.json1,017
impl/python/check_password.py4,443
impl/python/password_policy.py826
impl/rust/check_password.rs6,005
impl/rust/password_policy.rs1,488
impl/typescript/check_password.ts4,629
impl/typescript/password_policy.ts792
vectors.json10,622