5.0 KiB
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:
{
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 typeauth.password.errors.tooShort— belowAUTH_PASSWORD_MIN_LENGTHauth.password.errors.tooLong— above the absolute max (1024)auth.password.errors.missingUpperauth.password.errors.missingLowerauth.password.errors.missingDigitauth.password.errors.missingSpecialauth.password.errors.containsEmailauth.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=trueand bumpAUTH_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
hibpBypassedin the audit log; if the rate sustains, setAUTH_PASSWORD_HIBP_CHECK=falseto drop the 3 s per-write timeout. Re-enable when api.pwnedpasswords.com recovers.