password-schema
EditValidates an NFC-normalized password by Unicode code-point length without trimming it.
import * as z from "zod";
const DEFAULT_PASSWORD_MIN_LENGTH = 8;const DEFAULT_PASSWORD_MAX_LENGTH = 128;const codePointLength = (value: string): number => Array.from(value).length;const isWellFormedUnicode = (value: string): boolean => { for (let index = 0; index < value.length; index += 1) { const codeUnit = value.charCodeAt(index); if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { const nextCodeUnit = value.charCodeAt(index + 1); if (!(nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)) return false; index += 1; } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { return false; } } return true;};
export interface PasswordSchemaOptions { /** Minimum number of Unicode code points. */ readonly minLength?: number; /** Maximum number of Unicode code points. */ readonly maxLength?: number; /** * Complete passwords to reject. Entries and inputs are normalized to NFC * before an exact, case-sensitive comparison. */ readonly blocklist?: ReadonlySet<string>;}
interface ValidatedPasswordOptions { readonly minLength: number; readonly maxLength: number; readonly blocklist: ReadonlySet<string>;}
const validateOptions = ( options: PasswordSchemaOptions,): ValidatedPasswordOptions => { if (typeof options !== "object" || options === null || Array.isArray(options)) { throw new TypeError("options must be a plain object"); }
const prototype = Object.getPrototypeOf(options) as unknown; if (prototype !== Object.prototype && prototype !== null) { throw new TypeError("options must be a plain object"); }
for (const key of Reflect.ownKeys(options)) { if ( key !== "minLength" && key !== "maxLength" && key !== "blocklist" ) { throw new TypeError(`Unknown password option: ${String(key)}`); } }
const minLength = Object.hasOwn(options, "minLength") ? (options.minLength ?? DEFAULT_PASSWORD_MIN_LENGTH) : DEFAULT_PASSWORD_MIN_LENGTH; const maxLength = Object.hasOwn(options, "maxLength") ? (options.maxLength ?? DEFAULT_PASSWORD_MAX_LENGTH) : DEFAULT_PASSWORD_MAX_LENGTH;
if (!Number.isSafeInteger(minLength) || minLength <= 0) { throw new RangeError("options.minLength must be a positive safe integer"); } if (!Number.isSafeInteger(maxLength) || maxLength <= 0) { throw new RangeError("options.maxLength must be a positive safe integer"); } if (minLength > maxLength) { throw new RangeError( "options.minLength must be less than or equal to options.maxLength", ); }
const blocklist = Object.hasOwn(options, "blocklist") ? options.blocklist : undefined; if (blocklist === undefined) { return { minLength, maxLength, blocklist: new Set<string>() }; }
if ( typeof blocklist !== "object" || blocklist === null ) { throw new TypeError("options.blocklist must be a ReadonlySet of strings"); }
const normalizedBlocklist = new Set<string>(); let values: IterableIterator<unknown>; try { values = Set.prototype.values.call(blocklist as Set<unknown>); } catch (error) { throw new TypeError("options.blocklist must be a ReadonlySet of strings", { cause: error, }); }
try { for (const value of values) { if (typeof value !== "string") { throw new TypeError( "options.blocklist must contain only string values", ); } if (!isWellFormedUnicode(value)) { throw new TypeError( "options.blocklist must contain only well-formed Unicode strings", ); } normalizedBlocklist.add(value.normalize("NFC")); } } catch (error) { if ( error instanceof TypeError && (error.message === "options.blocklist must contain only string values" || error.message === "options.blocklist must contain only well-formed Unicode strings") ) { throw error; } throw new TypeError("options.blocklist must be a ReadonlySet of strings", { cause: error, }); }
return { minLength, maxLength, blocklist: normalizedBlocklist };};
/** * Creates an NFC-normalized password schema with configurable code-point * bounds and an optional full-value blocklist. */export const createPasswordSchema = (options: PasswordSchemaOptions = {}) => { const { minLength, maxLength, blocklist } = validateOptions(options);
return z .string({ error: "Password must be a string" }) .check( z.refine(isWellFormedUnicode, { error: "Password must contain only well-formed Unicode", }), z.normalize("NFC"), z.refine((value) => codePointLength(value) >= minLength, { error: `Password must contain at least ${minLength} characters`, }), z.refine((value) => codePointLength(value) <= maxLength, { error: `Password must contain at most ${maxLength} characters`, }), z.refine((value) => !blocklist.has(value), { error: "Password is blocked", }), ) .brand<"ValidatedPassword">() .meta({ title: "Password", description: `An NFC-normalized password containing ${minLength} to ${maxLength} Unicode code points.`, examples: ["correct horse battery staple"], });};
export const passwordSchema = createPasswordSchema().brand<"Password">();
export type ValidatedPassword = z.infer< ReturnType<typeof createPasswordSchema>>;export type Password = z.infer<typeof passwordSchema>;Download
Section titled “Download”wget -O src/schemas/password-schema.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/zod-schemas/password-schema.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/zod-schemas "$temp_dir" && mv "$temp_dir/password-schema.ts" src/schemas/password-schema.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { createPasswordSchema, passwordSchema, type Password, type ValidatedPassword,} from "./password-schema";
const password = passwordSchema.parse("correct horse battery staple");
const savePassword = (value: Password) => { console.log(`Accepted ${Array.from(value).length} characters`);};
savePassword(password);
const pinSchema = createPasswordSchema({ minLength: 6, maxLength: 12, blocklist: new Set(["123456"]),});
const pin: ValidatedPassword = pinSchema.parse("739251");
// Configurable schemas prove validation, but not the default 8–128 policy.// @ts-expect-error a custom-policy password is not a default PasswordsavePassword(pin);createPasswordSchema(options)— Creates a password schema. The exportedpasswordSchemacalls it with the default empty options.options.minLength(number, default:8) — Minimum Unicode code-point length. It must be a positive safe integer.options.maxLength(number, default:128) — Maximum Unicode code-point length. It must be a positive safe integer greater than or equal tominLength.options.blocklist(ReadonlySet<string>, optional) — Complete password values to reject. Entries are NFC-normalized and copied when the schema is created.input(string) — The password to normalize and validate.
Options must be a plain object. Unknown properties, invalid bounds, non-set blocklists, and blocklists containing non-string or malformed Unicode values throw when the schema is created. Only own option properties are read; inherited values are ignored.
Returns
Section titled “Returns”createPasswordSchema returns an NFC-normalized string branded with
"ValidatedPassword". The exported passwordSchema preset adds the
"Password" brand, uses 8 to 128 code points, and starts with an empty
blocklist. This distinction prevents a weaker custom policy from being passed
where the default Password type is required. Valid surrogate pairs count as
one code point; unpaired UTF-16 surrogates are rejected as malformed Unicode.
Whitespace is preserved, including at the beginning and end. All character classes are allowed, so passphrases, symbols, and non-ASCII text work without composition rules. Blocklist checks compare the complete normalized value exactly and case-sensitively; substrings and unlisted variants are not rejected. Use the policy and blocklist when creating or changing a password. During authentication, apply the same NFC normalization before hashing or comparison, but do not reject an existing credential because a later policy or blocklist changed.
The eight-code-point preset is for contexts where the password is used as part
of multi-factor authentication and the surrounding policy permits it. Current
NIST SP 800-63B-4
guidance requires at least 15 code points for a single-factor password; use
strongPasswordSchema for that length preset.