Password policy
Per-tenant and per-group password rules aligned with NIST 800-63B and ANSSI, length first, breach checks done entirely locally, and compliance options when a framework mandates them.
Obexal follows the modern guidance of NIST 800-63B and ANSSI: length and blocklists protect accounts, forced complexity and periodic expiration do not. The defaults reflect that, and everything the guidance discourages is off by default but available for compliance frameworks that mandate it. The policy applies wherever a password is set: sign-up, invitation activation, password change and reset.
Defaults aligned with NIST and ANSSI
| Setting | Default | Bounds |
|---|---|---|
minLength | 12 | 8 to 128 |
rejectBreached | true | |
rejectContextual | true | |
requireLower, requireUpper, requireDigit, requireSymbol | false | |
historyCount | 0 (disabled) | 0 to 24 |
maxAgeDays | 0 (never expires) | 0 to 3650 |
The default minimum length of 12 follows ANSSI 2024 (12 to 16 for user accounts) and NIST 800-63B Rev. 4. A tenant may lower it, but 8 is a hard floor enforced server-side whatever the configuration says. Passwords up to 256 characters are accepted, so passphrases work.
Compromised passwords, checked locally
With rejectBreached on, every candidate password is checked against two sources: an embedded list of trivial passwords, and an optional larger corpus in HIBP format (SHA1:count lines, or plain text with one password per line) loaded at startup from a local file (BREACHED_PASSWORD_FILE). Comparison is done by SHA-1 hash, entirely in memory.
Zero external calls are made: no query to the HaveIBeenPwned API or any third party, the password (or any derivative of it) never leaves the platform. See Data residency and sovereignty. When self-hosting, mount the corpus yourself, see Configuration.
Contextual passwords and the entropy guard
With rejectContextual on, a password containing the user's email (its local part) or name is refused, case-insensitively, for fragments of 4 characters or more. alice.martin2026! is not a password when your login is alice.martin@example.com.
Independently of any setting, an entropy guard is always active. It is deliberately conservative and only rejects two flagrant patterns that pass the length check: fewer than 4 distinct characters (aaaaaaaaaaaa, abababababab) and strict ascending or descending sequences (abcdefghijkl). A real passphrase never trips it.
Compliance options
Character-class requirements (requireLower, requireUpper, requireDigit, requireSymbol), reuse history (historyCount) and periodic expiration (maxAgeDays) are exposed for frameworks that mandate them, and off by default because NIST and ANSSI advise against them.
- History refuses a new password matching any of the last N passwords (verified against stored hashes, N up to 24). On a storage incident the check is skipped rather than blocking a legitimate change.
- With expiration on, a correct but expired password is refused at sign-in (no session opened, generic
password_expiredaudit reason); the user renews it through the password reset flow.
Per-group overrides, strictest wins
On top of the tenant policy, a group can carry an override: the effective policy for a user is the field-by-field merge of the tenant policy and the overrides of all their groups, always keeping the most demanding value. Lengths and history take the maximum, boolean requirements are OR-ed, expiration takes the smallest non-zero age. An override can therefore only harden, never weaken. Users are managed in groups.
| Method and path | Effect |
|---|---|
GET /v1/admin/password-policy/groups | List group overrides |
PUT /v1/admin/password-policy/groups/{groupId} | Set a group override |
DELETE /v1/admin/password-policy/groups/{groupId} | Remove it (back to tenant policy) |
Storage, Argon2id
Passwords are hashed with Argon2id and stored in PHC string format ($argon2id$v=19$m=...,t=...,p=...$salt$hash). They are never stored or logged in clear, and validation errors return a generic message that does not echo the candidate.
Manage the policy over the API
Both endpoints require the tenant:manage permission, with an Admin API token (Authorization: Bearer obx_...). A custom domain replaces accounts.obexal.com. Every change is written to the audit log.
curl -sS https://accounts.obexal.com/v1/admin/password-policy \
-H "Authorization: Bearer $OBEXAL_API_TOKEN"{
"policy": {
"minLength": 12,
"rejectBreached": true,
"rejectContextual": true,
"requireLower": false, "requireUpper": false,
"requireDigit": false, "requireSymbol": false,
"historyCount": 0,
"maxAgeDays": 0
}
}curl -sS -X PUT https://accounts.obexal.com/v1/admin/password-policy \
-H "Authorization: Bearer $OBEXAL_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"minLength": 14, "rejectBreached": true, "rejectContextual": true,
"requireLower": false, "requireUpper": false, "requireDigit": false,
"requireSymbol": false, "historyCount": 5, "maxAgeDays": 0}'
# 204 No ContentThe PUT replaces the whole policy: send every field, not just the one you change.