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.0 (not the latest) · 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.
- passwordPolicy (name: string) -> PasswordPolicy
- checkPassword (password: string, email: string?, name: string?, policy: PasswordPolicy) -> PasswordCheck
The types it declares, generated into your project
/** The rules a new password has to meet. */
export interface PasswordPolicy {
readonly name: string;
/** in characters (code points), 1 or more */
readonly minLength: number;
/** in characters, at least minLength */
readonly maxLength: number;
/** 0 to 4 of: lower-case, capitals, digits, anything else */
readonly minCharacterClasses: number;
/** refuse passwords on the common-password list */
readonly blockCommon: boolean;
/** refuse passwords containing the email's local part or a word of the name */
readonly blockPersonal: boolean;
}
/** Whether a password meets a policy, and every reason it does not. */
export interface PasswordCheck {
readonly valid: boolean;
/** empty when valid */
readonly failures: readonly PasswordFailure[];
}
/** One broken rule. */
export interface PasswordFailure {
readonly code: PasswordFailureCode;
/** a sentence for the form, such as "Use at least 15 characters." */
readonly message: string;
}
export type PasswordFailureCode = "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.
passwordPolicy throws on bad input 8 tests
export function passwordPolicy(name: string): PasswordPolicy
| name | string | nist-800-63b-4-single-factor, nist-800-63b-4-multi-factor or composition-12-3 |
| returns | PasswordPolicy |
For example
passwordPolicy(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 characterspasswordPolicy(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 8passwordPolicy(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
import { passwordPolicy } from "#fune/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.
import { POLICIES } from "./auth_password_policy_data.ts"; ← this capability’s own data, compiled from data/policies.json into the same file by fune build
import { type PasswordPolicy } from "./auth_password_policy_types.ts";
/**
* 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.
*/
export function passwordPolicy(name: string): PasswordPolicy {
for (const row of POLICIES) {
if (row.name === name) {
return {
name: row.name,
minLength: row.minLength,
maxLength: row.maxLength,
minCharacterClasses: row.minCharacterClasses,
blockCommon: row.blockCommon,
blockPersonal: row.blockPersonal,
};
}
}
throw new RangeError(`unknown password policy "${name}"; known policies: ${POLICIES.map((row) => row.name).join(", ")}`);
}checkPassword throws on bad input 23 tests
export function checkPassword(password: string, email: string | null, name: string | null, 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
checkPassword(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 itcheckPassword(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 listcheckPassword(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
import { checkPassword } from "#fune/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.
import { COMMON_PASSWORDS } from "./auth_password_policy_data.ts"; ← this capability’s own data, compiled from data/policies.json into the same file by fune build
import { type PasswordCheck, type PasswordFailure, type PasswordPolicy } from "./auth_password_policy_types.ts";
/** Words shorter than this in a name or email are not refused inside a password: "al" or "jo" would refuse half of all passwords. */
const MIN_PERSONAL = 3;
let commonSet: Set<string> | null = null;
/** ASCII-only lower-casing, the same in every language (toLowerCase folds non-ASCII letters Python and Rust fold differently). */
function asciiLower(text: string): string {
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
out += c >= 65 && c <= 90 ? String.fromCharCode(c + 32) : text[i];
}
return out;
}
function isAsciiAlnum(ch: string): boolean {
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9");
}
/** Pieces of text between ASCII punctuation and spaces, lower-cased, of 3 or more characters. */
function words(text: string): string[] {
const out: string[] = [];
let current = "";
for (const ch of Array.from(text)) {
if (ch.length === 1 && ch.charCodeAt(0) < 128 && !isAsciiAlnum(ch)) {
if (Array.from(current).length >= MIN_PERSONAL) out.push(asciiLower(current));
current = "";
} else {
current += ch;
}
}
if (Array.from(current).length >= MIN_PERSONAL) out.push(asciiLower(current));
return out;
}
function checkPolicy(policy: PasswordPolicy): void {
if (typeof policy !== "object" || policy === null) throw new TypeError("policy must be a PasswordPolicy");
if (!Number.isInteger(policy.minLength) || policy.minLength < 1) {
throw new RangeError("policy minLength must be a whole number of at least 1");
}
if (!Number.isInteger(policy.maxLength) || policy.maxLength < policy.minLength) {
throw new RangeError("policy maxLength must be a whole number no smaller than minLength");
}
if (!Number.isInteger(policy.minCharacterClasses) || policy.minCharacterClasses < 0 || policy.minCharacterClasses > 4) {
throw new RangeError("policy minCharacterClasses must be a whole number from 0 to 4");
}
}
/**
* 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, for instant feedback, and on the server, which decides.
*/
export function checkPassword(password: string, email: string | null, name: string | null, policy: PasswordPolicy): PasswordCheck {
if (typeof password !== "string") throw new TypeError("password must be a string");
checkPolicy(policy);
const failures: PasswordFailure[] = [];
const chars = Array.from(password);
if (chars.length < policy.minLength) {
failures.push({ code: "too_short", message: `Use at least ${policy.minLength} characters.` });
}
if (chars.length > policy.maxLength) {
failures.push({ code: "too_long", message: `Use no more than ${policy.maxLength} characters.` });
}
if (policy.minCharacterClasses > 0) {
let lower = 0, upper = 0, digit = 0, other = 0;
for (const ch of chars) {
if (ch >= "a" && ch <= "z") lower = 1;
else if (ch >= "A" && ch <= "Z") upper = 1;
else if (ch >= "0" && ch <= "9") digit = 1;
else other = 1;
}
if (lower + upper + digit + other < policy.minCharacterClasses) {
failures.push({
code: "too_few_character_classes",
message: `Use at least ${policy.minCharacterClasses} of these: lower-case letters, capital letters, digits, symbols.`,
});
}
}
const folded = asciiLower(password);
if (policy.blockCommon) {
if (commonSet === null) commonSet = new Set(COMMON_PASSWORDS.map((row) => row.password));
if (commonSet.has(folded)) {
failures.push({ code: "too_common", message: "This password is too common. Choose something harder to guess." });
}
}
if (policy.blockPersonal) {
if (typeof email === "string") {
const at = email.lastIndexOf("@");
const local = at >= 0 ? email.slice(0, at) : email;
const candidates = words(local);
if (Array.from(local).length >= MIN_PERSONAL) candidates.push(asciiLower(local));
if (candidates.some((word) => folded.includes(word))) {
failures.push({ code: "contains_email", message: "Do not include your email address in your password." });
}
}
if (typeof name === "string" && words(name).some((word) => folded.includes(word))) {
failures.push({ code: "contains_name", message: "Do not include your name in your password." });
}
}
return { valid: failures.length === 0, failures };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript 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 TypeScript implementation. Install it without the registry with fune add ./auth.password-policy-1.0.0-typescript.fune, or fetch it from a terminal with fune pull auth.password-policy@1.0.0:typescript.
The whole function, every language, is one file too: auth.password-policy-1.0.0.fune, 91,189 bytes, sha256 00a1ca90ed5beb7fb652770a21eb657e590b55e6ee1973e51c483ef6c564c4b6. 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).