Skip to content

Password policy

Every newly set password must satisfy the server-side policy:

  1. Length - at least Z4J_PASSWORD_MIN_LENGTH characters. The default is 12, and 8 is the lowest value you can configure. The policy rejects more than 256 characters; invitation acceptance has a stricter request-body maximum of 200 characters.
  2. Character classes below 16 characters - a password shorter than 16 characters needs at least 3 of 4 of lowercase, uppercase, digit, and symbol. Whitespace does not count as a symbol. A password of 16 characters or more skips this composition check.
  3. Denylist - the case-folded password must not match the built-in weak- password list.

The denylist check still applies to passwords of 16 characters or more.

The same validate_policy() implementation runs on every supported path that sets a new password:

  • First-boot form: GET /setup, submitted to POST /api/v1/setup/complete
  • CLI first-admin creation: z4j bootstrap-admin --password-stdin or z4j createsuperuser --password-stdin
  • First-boot auto-provisioning through Z4J_BOOTSTRAP_ADMIN_EMAIL and Z4J_BOOTSTRAP_ADMIN_PASSWORD
  • Project-invitation signup: POST /api/v1/invitations/accept
  • Self-service password change: POST /api/v1/auth/change-password
  • Token-based password reset: POST /api/v1/auth/password-reset/confirm
  • Admin user creation: POST /api/v1/users
  • Admin-driven password reset: POST /api/v1/users/{user_id}/password
  • CLI password reset: z4j changepassword <email> --password-stdin

The first-boot environment variables are read during brain startup and are honoured only while the users table is empty. They are not a recovery path for an existing installation; use z4j changepassword instead.

Existing passwords are grandfathered when you tighten the policy. Login does not run validate_policy() again. It may rehash the supplied password, but it does not force the user to replace a password that no longer meets the current policy.

The current list contains roughly 6,800 entries:

  • 82 hand-curated common guesses drawn from OWASP and NIST guidance, the SecLists top-100 list, and z4j's supported stack
  • About 6,700 generated seasonal, year, and product-name variants
  • Seasonal and year variants for the fixed 2015-2029 range; the range does not advance automatically

_generate_patterns() in z4j_brain.auth.common_passwords builds the generated set at module import from hard-coded inputs. It is not a generated build artefact.

This compact local list is not a breach-corpus lookup. z4j does not currently query Have I Been Pwned or another external breached-password service.

Passwords are hashed with argon2id. Defaults are:

  • time_cost=3
  • memory_cost=64 MiB
  • parallelism=4
  • 32-byte hash and 16-byte salt

Only the work parameters are configurable:

  • Z4J_ARGON2_TIME_COST
  • Z4J_ARGON2_MEMORY_COST (KiB)
  • Z4J_ARGON2_PARALLELISM

Hash length and salt length are fixed in code. Other Z4J_ARGON2_* names are ignored rather than rejected. Benchmark verification on the hardware that will run the brain before sizing login capacity or changing the work factors.

After a successful login, z4j rehashes a stored hash whenever its parameters differ from the current configuration. This happens in either direction: raising the Argon2 work factors upgrades hashes as users log in, while lowering them also rewrites stronger hashes to the lower settings.

validate_policy() uses these internal PasswordError.code values:

  • password_too_short
  • password_too_long
  • password_too_simple
  • password_in_breach_list

They are not currently an HTTP API contract. Request-schema length violations return 422 before the policy runs. Other policy failures currently fall through the generic error middleware and return HTTP 500 with internal_error; the specific reason is present only in the brain log. CLI password-setting commands print the policy message and exit non-zero. Do not make an API client branch on the four internal codes.

The absent-account branch of login verifies against a real dummy hash generated at each brain-process boot. Login also holds success and failure responses to the configured minimum duration (Z4J_LOGIN_MIN_DURATION_MS, default 300 ms). These controls reduce account-existence timing differences; they are not a latency guarantee.

The older letter-plus-digit rule admitted low-entropy dictionary-plus-digit passwords. Requiring three character classes for passwords below 16 characters closes that short-password case while still permitting longer passphrases.

The dashboard does not currently estimate password strength. Its password fields use the minimum length returned by GET /api/v1/auth/policy; the server remains the authority for composition and denylist checks. Because non-length policy failures currently surface as generic internal_error responses, the dashboard cannot display their specific policy reason.