Password Validation Patterns

Password fields fail users in a very particular way: the rules are invisible until they are broken, the error arrives after the user has already typed twelve characters they cannot see, and the message (“Password does not meet requirements”) names no requirement at all. On top of that, many teams still enforce composition rules — one uppercase, one symbol, one digit — that current guidance (NIST SP 800-63B) explicitly discourages, while skipping the checks that actually matter: minimum length and screening against known-breached passwords. This topic rebuilds password validation on the site’s standard baseline — a <form novalidate> driven by the Constraint Validation API — and layers length rules, live requirement feedback, strength estimation and breach screening on top without ever hiding the real constraint from assistive technology.

The pain point this solves is concrete: a sign-up form where 20–30% of first submissions fail on the password field, where screen-reader users hear nothing while a visual checklist silently turns green, and where “P@ssw0rd!” passes every rule while “correct horse battery staple” fails them. Every pattern below is aimed at one of those three failures.

Password validation pipeline A password value passes through native length constraints, a live requirement checklist, a strength estimate and a debounced breach check before the form is allowed to submit; failing stages feed one accessible error. Native constraints minlength=12, maxlength=128, required Requirement checklist live rule list, polite announcements Strength estimate entropy / zxcvbn-style score Breach screening k-anonymity range lookup, debounced One accessible message via setCustomValidity + aria-describedby
Cheap synchronous checks run first on every keystroke; the network-bound breach check runs last, debounced, and only once the cheap rules pass.

Prerequisites for the Password Validation Stack

The patterns here use only platform APIs plus optional, lazily loaded helpers. The breach check relies on crypto.subtle.digest, which is only exposed in secure contexts, so local development needs localhost or HTTPS.

Requirement Minimum version Why it is needed
TypeScript 5.0+ satisfies, const type parameters in the rule table
crypto.subtle.digest("SHA-1") All evergreen browsers, secure context only Hashing the candidate password for the k-anonymity range query
AbortController Chrome 66, Firefox 57, Safari 12.1 Cancelling stale breach lookups as the user keeps typing
:user-invalid Chrome 119, Firefox 88, Safari 16.5 Styling the field only after interaction
Optional: zxcvbn-ts 3.x, dynamically imported Dictionary-aware strength scoring without shipping 400 kB up front
autocomplete="new-password" All browsers Lets password managers generate a compliant password

Password Constraint API Reference

These are the attributes, properties and events the rest of the page leans on. The novalidate baseline means none of them block submission on their own — your submit handler calls reportValidity() and decides.

API Type / values Returns / effect Notes
minlength / maxlength integer attributes set validity.tooShort / tooLong tooShort only fires after a user edit, never for a script-set value
required boolean attribute sets validity.valueMissing Pair with a visible “required” indicator
setCustomValidity(msg) (msg: string) => void sets validity.customError Use for breach and strength verdicts the attributes cannot express
validity.valid boolean aggregate of all flags Read after every rule update
autocomplete="new-password" token hints password generators current-password on sign-in forms
passwordrules Safari-only attribute shapes generated passwords Progressive enhancement; ignored elsewhere
input event InputEvent fires per edit Drive the checklist here, not on keyup
aria-describedby id list links rules + error to the field Reference both the checklist and the error container

Step-by-Step Implementation

1. Declare the length constraint in HTML first

Length is the single rule that most improves password quality, so it belongs in markup where it works before any script loads. Note there is no pattern attribute: composition rules are deliberately absent.

<form id="signup" novalidate>
  <label for="pw">Password</label>
  <input id="pw" name="password" type="password"
         required minlength="12" maxlength="128"
         autocomplete="new-password"
         aria-describedby="pw-rules pw-error">
  <ul id="pw-rules" class="pw-rules"></ul>
  <p id="pw-error" class="field-error" hidden></p>
  <button type="submit">Create account</button>
</form>

2. Model the rules as data, not branches

A rule table keeps the checklist UI, the error text and the tests in sync. Each rule is a pure predicate, which makes it trivial to unit test (see composing pure validator functions).

export interface PasswordRule {
  id: string;
  label: string;               // shown in the checklist
  test: (value: string, ctx: { email?: string }) => boolean;
}

export const PASSWORD_RULES = [
  { id: "length", label: "At least 12 characters", test: (v) => [...v].length >= 12 },
  { id: "max", label: "No more than 128 characters", test: (v) => [...v].length <= 128 },
  {
    id: "not-email",
    label: "Does not contain your email address",
    test: (v, ctx) => !ctx.email || !v.toLowerCase().includes(ctx.email.split("@")[0].toLowerCase()),
  },
] as const satisfies readonly PasswordRule[];

export function evaluateRules(value: string, ctx: { email?: string }) {
  // Spread into code points so emoji and astral characters count as one character.
  return PASSWORD_RULES.map((r) => ({ id: r.id, label: r.label, met: r.test(value, ctx) }));
}

3. Render the checklist and keep the native verdict authoritative

The checklist is a reading aid; the field’s validity remains the source of truth. When every rule passes we clear the custom error; otherwise we set one that names the first unmet rule so reportValidity() has something specific to say.

const form = document.querySelector<HTMLFormElement>("#signup")!;
const pw = form.querySelector<HTMLInputElement>("#pw")!;
const list = form.querySelector<HTMLUListElement>("#pw-rules")!;
const email = form.querySelector<HTMLInputElement>("[name=email]");

function renderRules(): void {
  const results = evaluateRules(pw.value, { email: email?.value });
  list.replaceChildren(
    ...results.map((r) => {
      const li = document.createElement("li");
      li.dataset.met = String(r.met);
      // Text carries the state too, so colour is never the only signal.
      li.textContent = `${r.met ? "Met" : "Not met"}: ${r.label}`;
      return li;
    }),
  );
  const firstUnmet = results.find((r) => !r.met);
  pw.setCustomValidity(firstUnmet ? `Password must be: ${firstUnmet.label.toLowerCase()}` : "");
}

pw.addEventListener("input", renderRules);
renderRules();

4. Gate submission through the canonical manual call

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  renderRules();
  if (!form.reportValidity()) return;      // focuses + announces the first failure
  const breached = await isBreached(pw.value); // step 5
  if (breached) {
    pw.setCustomValidity("This password appeared in a data breach. Choose a different one.");
    pw.reportValidity();
    return;
  }
  form.submit();
});

5. Screen against breached passwords with k-anonymity

Only the first five hex characters of the SHA-1 hash leave the browser; the range response contains every suffix sharing that prefix, and the match happens locally. The full recipe, including padding and caching, is in checking passwords against breached lists.

async function sha1Hex(value: string): Promise<string> {
  const buf = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(value));
  return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
}

export async function isBreached(value: string, signal?: AbortSignal): Promise<boolean> {
  const hash = await sha1Hex(value);
  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);
  // Proxy through your own origin so no third-party request leaves the page.
  const res = await fetch(`/api/pwned-range/${prefix}`, { signal });
  if (!res.ok) return false; // fail open: never block sign-up on an outage
  const body = await res.text();
  return body.split("\n").some((line) => line.startsWith(suffix));
}
k-anonymity breach lookup The browser hashes the password, sends only a five-character prefix to its own proxy, receives every matching suffix and compares locally. Browser Your origin proxy Breach range API SHA-1 hash computed locally with crypto.subtle GET /api/pwned-range/5BAA6 range request for prefix 5BAA6 ~800 suffixes with counts cached suffix list suffix compared locally, verdict set via setCustomValidity
The full password and full hash never leave the browser; the proxy only ever sees a prefix shared by hundreds of unrelated hashes.

State Management and Edge Cases

A password field moves through more states than an ordinary text input because one of its checks is asynchronous. Modelling it as a small state machine prevents the classic bug where a slow breach response for an old value overwrites the verdict for the current one.

Password field state machine The field moves from empty to checking rules, to screening once local rules pass, and ends in accepted or rejected; any edit returns it to checking rules. empty checking rules screening accepted rejected input rules pass not found found in breach edit edit
Every edit returns the field to local rule checking and aborts any in-flight breach lookup, so a stale response can never land.

The edge cases worth handling explicitly:

  • Paste of a long passphrase. A 200-character paste exceeds maxlength; browsers truncate typed input at maxlength but some password managers set .value directly, bypassing the limit. Keep the max rule in the table so the custom error catches it.
  • Race between keystrokes and the breach response. Debounce the lookup (400–600 ms) and abort the previous request on every edit — the pattern from cancelling stale requests with AbortController.
  • Unicode normalisation. “é” can be one code point or two. Normalise with value.normalize("NFKC") before hashing and before counting length, and do the same on the server, or the same password hashes differently on two devices.
  • Confirmation fields. If you keep a “confirm password” input, validate it with the cross-field password confirmation pattern and re-run it whenever the first field changes.
let controller: AbortController | undefined;
let timer: number | undefined;

pw.addEventListener("input", () => {
  controller?.abort();                 // drop any in-flight lookup for an older value
  window.clearTimeout(timer);
  const value = pw.value.normalize("NFKC");
  if (!evaluateRules(value, {}).every((r) => r.met)) return; // local rules first
  timer = window.setTimeout(async () => {
    controller = new AbortController();
    try {
      const hit = await isBreached(value, controller.signal);
      if (pw.value.normalize("NFKC") !== value) return; // value moved on
      pw.setCustomValidity(hit ? "This password appeared in a data breach." : "");
    } catch (e) {
      if ((e as DOMException).name !== "AbortError") throw e;
    }
  }, 500);
});

Accessibility Compliance for Password Fields

Password validation touches three WCAG 2.2 success criteria directly. 3.3.1 Error Identification requires the failure to be described in text — “Password must be at least 12 characters”, not a red border. 3.3.3 Error Suggestion requires the fix to be suggested when known, which the rule labels do for free. 3.3.8 Accessible Authentication (Minimum), new in 2.2, forbids cognitive function tests during authentication unless a mechanism assists — in practice that means you must never block paste into password fields or disable password managers, because copy-paste and autofill are the assisting mechanism.

Accessible password field anatomy A sign-up form mockup with a password field in an error state, a live requirement checklist and the ARIA bindings annotated. Create your account Email ada@example.com Password •••••••• 1 ✗ Password must be at least 12 characters ✗ At least 12 characters ✓ Does not contain your email address ○ Not found in known data breaches Create account 1 aria-describedby="pw-rules pw-error" so both the rule list and the verdict are read with the field 2 Checklist items state Met / Not met in text, never by colour or icon alone 3 Rule changes are not announced per keystroke; the verdict is announced on blur or submit 4 Paste and password-manager autofill stay enabled (WCAG 3.3.8)
The checklist and the error both hang off aria-describedby; the checklist updates silently while the error is announced only on submit or blur.

Do not put aria-live on the checklist itself: announcing every rule flip on every keystroke drowns out the characters the user is typing. Instead, announce a summary when the field loses focus, via a single polite live region that the screen-reader announcement pattern already establishes for other fields.

const status = document.querySelector<HTMLElement>("#pw-status")!; // role="status"

pw.addEventListener("blur", () => {
  const unmet = evaluateRules(pw.value, {}).filter((r) => !r.met);
  status.textContent = unmet.length === 0
    ? "Password meets all requirements."
    : `${unmet.length} password requirement${unmet.length > 1 ? "s" : ""} not met.`;
});

Common Gotchas and Debugging

Composition rules that reject strong passphrases. A pattern like ^(?=.*[A-Z])(?=.*\d)(?=.*\W).{8,}$ rejects a 30-character lowercase passphrase and accepts “Password1!”. Drop it.

<!-- Before: composition rules, short minimum -->
<input type="password" pattern="^(?=.*[A-Z])(?=.*\d)(?=.*\W).{8,}$">
<!-- After: length is the rule; strength and breach checks do the rest -->
<input type="password" minlength="12" maxlength="128" autocomplete="new-password">

tooShort never becomes true in tests. Setting input.value = "abc" from script does not trigger tooShort — the flag only reflects user edits. Tests that assert on it must type through the keyboard (page.keyboard.type in Playwright) or rely on your custom rule instead.

// Before: passes silently because tooShort stays false for programmatic values
pw.value = "abc";
expect(pw.validity.tooShort).toBe(true); // ✗ fails

// After: assert on the rule-driven custom error, which does not depend on edit provenance
pw.value = "abc";
pw.dispatchEvent(new Event("input"));
expect(pw.validity.customError).toBe(true);

Blocking paste. onpaste="return false" fails WCAG 3.3.8 and breaks password managers. There is no safe version of this; remove it.

Counting length with .length. "🔐".length is 2 in JavaScript. Use [...value].length or Intl.Segmenter so an emoji-heavy passphrase is not over-counted and truncated at the wrong point.

Trimming passwords. Calling .trim() on a password silently changes what the user typed. Never trim; if leading spaces are a support problem, show a hint rather than mutating the value.

Sign-In Forms Need Different Password Rules

Everything so far describes a new-password field. A sign-in field is a different animal, and reusing the sign-up validator on it is one of the most common mistakes in this area. On sign-in the only question is whether the credential matches; the user’s password was chosen under whatever policy existed years ago, possibly shorter than today’s minimum, and telling them “must be at least 12 characters” before they have even tried is both wrong and a small information leak about your current policy.

The sign-in field therefore carries required and autocomplete="current-password" and nothing else. No minlength, no checklist, no strength meter, no breach lookup before submission. The only error it should ever show on its own is “Enter your password” for an empty submit; everything else comes back from the server as a single, deliberately vague message attached to the form rather than to one field — “Email or password is incorrect” — so that the response does not confirm which accounts exist.

const signIn = document.querySelector<HTMLFormElement>("#signin")!;
const current = signIn.querySelector<HTMLInputElement>("[autocomplete=current-password]")!;

signIn.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (!signIn.reportValidity()) return;          // only valueMissing can fail here
  const res = await fetch("/api/session", { method: "POST", body: new FormData(signIn) });
  if (res.status === 401) {
    // Form-level, not field-level: never reveal which half of the credential was wrong.
    showFormError(signIn, "Email or password is incorrect.");
    current.value = "";
    current.focus();
  }
});

The breach screen still has a role at sign-in, but on the server and after a successful login: if the stored password now appears in a breach corpus, let the user in and immediately route them to a change-password screen that uses the full new-password validation described above.

Enforcing the Same Password Policy on the Server

Everything above improves the experience; none of it is security. A request crafted with curl skips the checklist, the strength meter and the breach lookup entirely, so the server must re-run the policy that matters — length bounds, the breach screen and any “not your email” rule — before hashing the password. The trick is to avoid writing the policy twice. Put the rule table in a module both bundles import, exactly as the shared client–server schemas topic recommends, and let each side add only what it alone can do: the browser adds the live checklist, the server adds the authoritative breach query against a local copy of the corpus rather than a network call per sign-up.

// password-policy.ts — imported by the browser bundle AND the API handler
import { z } from "zod";

export const MIN = 12;
export const MAX = 128;

export const passwordSchema = z
  .string()
  .transform((v) => v.normalize("NFKC"))
  .refine((v) => [...v].length >= MIN, { message: `Password must be at least ${MIN} characters` })
  .refine((v) => [...v].length <= MAX, { message: `Password must be at most ${MAX} characters` });

// server/signup.ts
export async function handleSignup(form: FormData) {
  const parsed = passwordSchema.safeParse(form.get("password"));
  if (!parsed.success) {
    return { status: 422, errors: { password: parsed.error.issues[0].message } };
  }
  if (await breachCorpus.has(sha1(parsed.data))) {
    return { status: 422, errors: { password: "This password appeared in a data breach." } };
  }
  // hash with argon2id / bcrypt and persist
}

Three details make this robust. First, normalise inside the shared schema so the browser and the server count and hash the same code points. Second, return field-keyed errors in the shape the server error mapping guide expects, so a server-side rejection lands on the password input with setCustomValidity() and reads exactly like a client-side one. Third, keep the maximum length on the server even though the client enforces it: slow hash functions such as bcrypt truncate at 72 bytes and argon2 costs grow with input, so an unbounded password field is a cheap denial-of-service vector.

What the client and the server each own in password validation Two columns listing which password checks belong in the browser for experience and which belong on the server for enforcement. Browser (experience) • live requirement checklist on every input • strength meter and suggestions • debounced breach pre-check through your proxy ✓ instant, specific feedback before submit ✗ bypassable with DevTools or curl Server (enforcement) • same length bounds from the shared module • breach screen against a local corpus • rate limiting on sign-up attempts ✓ authoritative and tamper-proof ✗ slower feedback, needs error mapping back to the field
The browser owns feedback; the server owns the verdict. The shared rule table is what keeps their messages identical.

Testing the Password Rule Table

Because the rules are data, the tests can be data too. A table-driven Vitest suite pins each rule to concrete inputs, including the Unicode and passphrase cases that regressions usually break. Keep one integration test that types through the real field, because only keyboard input exercises tooShort; the approach is covered in depth in testing required field validation in Playwright.

import { describe, it, expect } from "vitest";
import { evaluateRules } from "./password-rules";

const cases: Array<[string, string, boolean]> = [
  ["length", "short", false],
  ["length", "correct horse battery staple", true],
  ["length", "🔐🔐🔐🔐🔐🔐🔐🔐🔐🔐🔐🔐", true],   // 12 code points, 24 UTF-16 units
  ["not-email", "ada.lovelace-rocks-2026", false],
];

describe("password rules", () => {
  it.each(cases)("%s rule on %j → %s", (id, value, expected) => {
    const result = evaluateRules(value, { email: "ada.lovelace@example.com" }).find((r) => r.id === id)!;
    expect(result.met).toBe(expected);
  });
});

The emoji row is the one that earns its keep: it fails immediately if someone “simplifies” the length rule back to value.length, which would count 24 and let a 6-emoji password through a 12-character minimum.

Browser Compatibility Matrix

Password validation feature support Support across Chromium, Firefox and Safari for the platform features the password patterns depend on. Chromium Firefox Safari minlength / tooShort ✓ Yes ✓ Yes ✓ Yes crypto.subtle SHA-1 ✓ Yes ✓ Yes ✓ Yes :user-invalid ✓ 119+ ✓ 88+ ✓ 16.5+ passwordrules attribute ✗ No ✗ No ✓ Yes autocomplete=new-password ✓ Yes ✓ Yes ✓ Yes
Everything the baseline needs ships in all three engines; passwordrules is a Safari-only enhancement that other browsers simply ignore.
Feature Chromium Firefox Safari Fallback
minlength + tooShort Yes Yes Yes Custom rule in the table
crypto.subtle.digest Yes (secure context) Yes (secure context) Yes (secure context) Skip breach check on http:
:user-invalid 119+ 88+ 16.5+ [aria-invalid="true"] selector
passwordrules Ignored Ignored Honoured Rule text in the checklist
Password reveal button Edge only (built in) None None Custom toggle; hide Edge’s with ::-ms-reveal

Each sub-problem has its own focused guide: the accessible password strength meter, the live password requirements checklist, the accessible show/hide password toggle and the breach screening recipe linked above.

Frequently Asked Questions

Should I still require uppercase letters, digits and symbols?

No. Composition rules push users toward predictable substitutions like "Password1!" and reject long passphrases. Current guidance favours a generous minimum length (12 or more), a high maximum, and screening against known-breached passwords instead.

Is it safe to check passwords against a breach database from the browser?

Yes, with the k-anonymity range technique: only the first five characters of the SHA-1 hash are sent, and matching happens locally. Route the request through your own origin so the page makes no third-party calls, and fail open if the service is unavailable.

Why does my minlength check not fire when I set the value in a test?

The tooShort flag only reflects values the user edited. Script-assigned values never trigger it, so either type through the keyboard in the test or assert on a custom rule that sets setCustomValidity().

Should the requirement checklist be an aria-live region?

No — announcing rule changes on every keystroke competes with the typing echo. Reference the checklist from aria-describedby so it is read with the field, and announce a one-line summary on blur through a single polite status region.

← Back to Validating Common Input Types

Explore This Section