auth.validate-password-change
Check a change-password form: current password given and correct, new one meeting the policy and different.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 15 tests, run in TypeScript, Python and Rust.
What it does
Checks a change-password form in one call, after the API has asked `auth.password-hash` whether the current password is right, and answers in the shape an API's `validation_failed` error and a form both want:
matches = verifyPassword(currentPassword, user.passwordHash)
validatePasswordChange(currentPassword, newPassword, matches, user.email, user.name,
passwordPolicy("nist-800-63b-4-single-factor"))
# {valid: false, fields: {"currentPassword": "That is not your current password."}}
For example
validate_password_change(correct horse battery staple, a much longer passphrase here, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character c…)→ valid true, fields … the right current password and a good new onevalidate_password_change(correct horse battery stapel, a much longer passphrase here, false, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character …)→ valid false, fields … the current password is wrongvalidate_password_change(, a much longer passphrase here, false, 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…)→ valid false, fields … the current password is empty
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def validate_password_change(current_password: str, new_password: str, current_password_matches: bool, email: str, name: str, policy: PasswordPolicy) -> PasswordChangeCheck
| current_password | string | as typed |
| new_password | string | as typed; checked with auth.password-policy against the account's email and name |
| current_password_matches | bool | what auth.password-hash's verifyPassword said about currentPassword and the stored hash |
| string | the account's stored email, whose local part may not appear in the new password | |
| name | string | the account's stored name, whose words may not appear in the new password |
| policy | PasswordPolicy | usually passwordPolicy("nist-800-63b-4-single-factor"), the one sign-up uses |
| returns | PasswordChangeCheck | valid, or a message for each field that needs fixing |
The type it declares, generated into your project
@dataclass(frozen=True)
class PasswordChangeCheck:
"""A change-password form's verdict, shaped for an API's validation error and a form's field messages."""
valid: bool
#: field name (currentPassword, newPassword) to message; empty when valid
fields: Dict[str, str]
Your code names it in one line, in the file that uses it
from fune.auth.validate_password_change import validate_password_change # auth.validate-password-change@^1
from typing import Any, Dict, List
from .auth_password_policy_check_password import check_password
from .auth_password_policy_types import PasswordPolicy
from .auth_validate_password_change_types import PasswordChangeCheck
def _text(value: Any) -> str:
# A non-string (a malformed JSON body) is an empty field, not an exception.
return value if isinstance(value, str) else ""
def validate_password_change(
current_password: str,
new_password: str,
current_password_matches: bool,
email: str,
name: str,
policy: PasswordPolicy,
) -> PasswordChangeCheck:
"""A change-password form checked in one call: the current password must
be given and correct, the new one must meet the policy and differ from it."""
current, new = _text(current_password), _text(new_password)
fields: Dict[str, str] = {}
if current == "":
fields["currentPassword"] = "Enter your current password."
elif current_password_matches is not True:
fields["currentPassword"] = "That is not your current password."
# Checked even when empty, so a nonsensical policy always throws.
check = check_password(new, email, name, policy)
if new == "":
fields["newPassword"] = "Enter a new password."
else:
messages: List[str] = [f.message for f in check.failures]
if new == current:
messages.append("Choose a password that is different from your current one.")
if messages:
fields["newPassword"] = " ".join(messages)
return PasswordChangeCheck(valid=len(fields) == 0, fields=fields)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, 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.validate-password-change
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./auth.validate-password-change-1.0.0-python.fune, or fetch it from a terminal with fune pull auth.validate-password-change@1.0.0:python.
The whole function, every language, is one file too: auth.validate-password-change-1.0.0.fune, 17,269 bytes, sha256 c95e8fac3c2b80b7c7368d312f2ba8b40486e94e4e24148e6e53940224cd20c1. 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.validate-password-change
after — your function gets the result and the arguments, and returns the final result.
# fune: after auth.validate-password-change
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace auth.password-policy in auth.validate-password-change
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show auth.validate-password-change --steps.
# fune: step auth.validate-password-change 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| the right current password and a good new one | correct horse battery staple, a much longer passphrase here, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character c… | → | valid true, fields … |
| the current password is wrong | correct horse battery stapel, a much longer passphrase here, false, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character … | → | valid false, fields … |
| the current password is empty | , a much longer passphrase here, false, 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… | → | valid false, fields … |
| an empty current password never counts as matching, whatever the flag says | , a much longer passphrase here, true, 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,… | → | valid false, fields … |
| the new password is empty | correct horse battery staple, , true, 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, … | → | valid false, fields … |
| both empty: current first, then new | , , false, 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 personal true | → | valid false, fields … |
| the new password is too short | correct horse battery staple, short, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block common t… | → | valid false, fields … |
| the new password is short and common: every failure, joined | correct horse battery staple, password, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0, block commo… | → | valid false, fields … |
| the new password contains the account's email local part and name | correct horse battery staple, ada-lovelace-rules-ok, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character classes 0… | → | valid false, fields … |
| the new password is the current one | correct horse battery staple, correct horse battery staple, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character cl… | → | valid false, fields … |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the same and too short: the policy's failures come first | shortpass, shortpass, true, 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 pers… | → | valid false, fields … |
| a trailing space makes a different password: nothing is trimmed | correct horse battery staple, correct horse battery staple , true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 15, max length 128, min character c… | → | valid true, fields … |
| a wrong current password and a bad new one: both fields | nope, short, false, 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 personal true | → | valid false, fields … |
| values that are not strings (a malformed JSON body) are treated as empty | —, 12,345, false, 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 personal true | → | valid false, fields … |
| a nonsensical policy is the caller's mistake | correct horse battery staple, a much longer passphrase here, true, ada@example.com, Ada Lovelace, name nist-800-63b-4-single-factor, min length 0, max length 128, min character cl… | → | error: policy minLength must be a whole number of at least 1 |
More from the author
Checking the hash is not a pure function's job (it needs the stored hash), so its answer comes in as `currentPasswordMatches`; the rule of what to say about it lives here, next to the other messages. A wrong current password is a field message (400), not a 401: the person's session is fine, they mistyped.
**currentPassword**: empty is "Enter your current password."; otherwise `currentPasswordMatches` false is "That is not your current password.". An empty password never counts as matching, whatever the flag says.
**newPassword**: empty is "Enter a new password."; otherwise every failure of `auth.password-policy`'s `checkPassword` against the account's stored email and name, in the policy's order, and then "Choose a password that is different from your current one." when it is exactly the current password, all joined with a space. Use the same policy name sign-up uses, so a password that could not be chosen at sign-up cannot be chosen here either.
Passwords are compared exactly as typed: not trimmed and not case-folded, so `"correct horse battery staple "` with a trailing space is a different password. `fields` is keyed `currentPassword` then `newPassword`, the JSON body's own names. A value that is not a string is treated as empty. A nonsensical policy throws, as in `checkPassword`.
After a valid change the API stores a new hash and revokes every token the account holds (`auth.access-token`: bump the token version).
Files
| Path | Bytes |
|---|---|
| README.md | 1,997 |
| impl/python.py | 1,593 |
| impl/rust.rs | 2,352 |
| impl/typescript.ts | 1,566 |
| vectors.json | 6,140 |