# Password Policy Server-side password validation that every password-write endpoint goes through. Configurable via env without a code deploy. Implementation lives in `apps/web/app/services/auth/password-policy.server.ts`. ## Defaults (OWASP 2024 aligned) | Env var | Default | Effect | |---|---|---| | `AUTH_PASSWORD_MIN_LENGTH` | `12` | Minimum character count. | | `AUTH_PASSWORD_REQUIRE_UPPER` | `true` | Requires at least one A–Z character. | | `AUTH_PASSWORD_REQUIRE_LOWER` | `true` | Requires at least one a–z character. | | `AUTH_PASSWORD_REQUIRE_DIGIT` | `true` | Requires at least one 0–9 character. | | `AUTH_PASSWORD_REQUIRE_SPECIAL` | `false` | Requires at least one non-alphanumeric character. Off by default — adds friction without much entropy beyond the length floor. | | `AUTH_PASSWORD_HIBP_CHECK` | `true` | Consults `api.pwnedpasswords.com` via SHA-1 k-anonymity. The plaintext password never leaves the server; only the first 5 characters of its SHA-1 are sent. | | `AUTH_PASSWORD_HIBP_MAX_OCCURRENCES` | `0` | Accept passwords seen at most this many times in HIBP. `0` = reject any leak. | Also enforced regardless of env: - An absolute max length of 1024 characters, so the validate-then-hash chain can't be amplified into a DoS via 100 MB password strings. - If the caller supplies `ctx.email`, the password must not contain the email's local-part (case-insensitive), provided the local-part is ≥3 characters. Short local-parts (`x@example.com`) are skipped to avoid false positives. ## Enforcement points Every endpoint that writes a password runs the policy: | Route | Caller | |---|---| | `apps/web/app/utils/services/auth.service.server.ts` (`AuthService.validateRegistration`) | `validatePassword` before `users.db.server.ts` hashes/stores the password | | `apps/web/app/routes/reset.tsx` | UserValidationUtils.validatePasswords | | `apps/web/app/routes/invitation.$id.tsx` | UserValidationUtils.validatePasswords | | `apps/web/app/routes/admin/settings/profile.tsx` (change password) | validatePassword directly | | `apps/web/app/routes/app.$tenant/settings/profile.tsx` (change password) | validatePassword directly | | `apps/web/app/routes/admin/accounts/users.tsx` (admin reset) | validatePassword directly | | `apps/web/app/routes/app.$tenant/__portals/portals.$portal/users.tsx` (portal user creation / reset) | validatePassword directly | Login (`apps/web/app/modules/users/services/AuthService.ts`) deliberately does NOT run the strength policy. Enforcing 12-char + class requirements there would lock out every user with a pre-policy password instead of prompting them to rotate. The plain "is the field a non-empty 4+ char string" presence-check in `UserValidationUtils.validatePassword` is what guards the login form. The dashboard-banner soft-check from the detail-MD is a follow-up. ## HIBP fail-open If the HIBP API times out (>3 s), returns a 5xx, or refuses the connection, the validation result carries `hibpBypassed: true` and the password is accepted (the other policy rules still apply). Caller code can log the bypass so operators have a signal when api.pwnedpasswords.com is having a bad day. We chose fail-open over fail-closed because the alternative ("can't register new users while HIBP is down") is worse for availability than the alternative ("a newly-leaked password might slip through during an HIBP outage"). To force fail-closed during a known-bad incident: set `AUTH_PASSWORD_HIBP_CHECK=false` to skip HIBP entirely until the API recovers. (Yes, that's the same as the bypass — but the env switch makes it explicit and avoids the per-request 3 s timeout cost.) ## Error model Each failure produces a bounded i18n key plus optional non-secret args: ```ts { valid: false, errors: [ { code: "auth.password.errors.tooShort", args: { minLength: 12 } }, { code: "auth.password.errors.missingDigit" } ] } ``` Codes: - `auth.password.errors.empty` — input was empty / wrong type - `auth.password.errors.tooShort` — below `AUTH_PASSWORD_MIN_LENGTH` - `auth.password.errors.tooLong` — above the absolute max (1024) - `auth.password.errors.missingUpper` - `auth.password.errors.missingLower` - `auth.password.errors.missingDigit` - `auth.password.errors.missingSpecial` - `auth.password.errors.containsEmail` - `auth.password.errors.pwned` Routes are expected to surface the first error to the user; the i18n catalog should map every code above. ## Operator playbook - **Relax the policy** (e.g. temporary lower bound during a migration): set `AUTH_PASSWORD_MIN_LENGTH=10` + restart. The change takes effect on next request; no code deploy. - **Tighten the policy**: set `AUTH_PASSWORD_REQUIRE_SPECIAL=true` and bump `AUTH_PASSWORD_MIN_LENGTH=16`. Existing users keep their old passwords until they next rotate; the new policy applies only to writes. - **HIBP-Outage handling**: monitor `hibpBypassed` in the audit log; if the rate sustains, set `AUTH_PASSWORD_HIBP_CHECK=false` to drop the 3 s per-write timeout. Re-enable when api.pwnedpasswords.com recovers.