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.
- password_policy (name: string) -> PasswordPolicy
- 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
| name | string | nist-800-63b-4-single-factor, nist-800-63b-4-multi-factor or composition-12-3 |
| returns | PasswordPolicy |
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 characterspassword_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 8password_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
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
| password | string | the new password, exactly as typed |
| string? | the account's email, so its local part can be refused inside the password; null if unknown | |
| name | string? | the person's name, so its words can be refused inside the password; null if unknown |
| policy | PasswordPolicy | usually passwordPolicy("nist-800-63b-4-single-factor") |
| returns | PasswordCheck | valid, 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 itcheck_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 listcheck_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
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
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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.