Skip to content

password-schema

Edit

Validates an NFC-normalized password by Unicode code-point length without trimming it.

password-schema.ts
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>;
Terminal
wget -O src/schemas/password-schema.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/zod-schemas/password-schema.ts
password-schema.example.ts
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 Password
savePassword(pin);
  • createPasswordSchema(options) — Creates a password schema. The exported passwordSchema calls 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 to minLength.
  • 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.

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.