Local Backend
The local backend stores users directly in horizon.yaml. Passwords are Argon2id-hashed and verified with timing-safe comparison. Suitable for single-operator deployments, small teams, or environments where setting up LDAP is overkill.
Activate
auth:
backend: local
local:
users:
- username: admin
passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$...$..."
roles: [admin]
- username: alice
passwordHash: "$argon2id$v=19$..."
roles: [operator]
- username: bob
passwordHash: "$argon2id$v=19$..."
roles: [viewer]
Bootstrap rule: local.users must be non-empty when backend: local. The BFF refuses to start otherwise — there is no built-in default admin/admin.
Generate a password hash
pnpm --filter bff cli:hash
The CLI prompts (no echo), then prints the hash to stdout:
$argon2id$v=19$m=65536,t=3,p=4$XYZ.../...
Paste into passwordHash. The CLI does not store anything; copy the hash, lose the plaintext.
Algorithm: Argon2id with the node-argon2 defaults (m=65536, t=3, p=4). Hashes generated by other tools are accepted as long as they validate via the node-argon2 verifier.
How verification works
apps/bff/src/user/local.ts:
- Lookup user by
username. If not found, fall through to a dummy Argon2id verification against a sentinel hash. This makes “user does not exist” indistinguishable from “wrong password” via timing — preventing username enumeration. - If found,
argon2.verify(passwordHash, typedPassword). - On match, return
{ username, roles }. - On mismatch, return
null.
The BFF logs success / failure through the audit log (see Audit Log) but never logs the typed password.
Operations
| Action | How |
|---|---|
| Add a user | Append to local.users in horizon.yaml. Hot-reload picks it up; no restart needed. |
| Remove a user | Delete the entry. Existing sessions for that user are not invalidated immediately — they expire on their next request when the session lookup finds no matching active user (or just on session TTL). For immediate revocation, restart the BFF. |
| Reset a password | Generate a new hash, replace passwordHash. The next login uses the new hash; existing sessions stay valid until TTL. |
| Change a user’s roles | Edit roles. Existing sessions retain the role list captured at login time — they will not pick up the change until re-login. New logins get the new role list. |
The session captures the role list at authentication time, not on every request. This is deliberate — it means a hot-reloaded role change does not retroactively grant additional access mid-session.
File permissions
horizon.yaml contains password hashes. Treat it as a secret-bearing file:
- Filesystem perms
0600(BFF user only). - Not in version control. The repo’s
.gitignoreexcludeshorizon.yaml; onlyhorizon.example.yamlis committed. - If you store the file in configuration management (Ansible, Helm secret, etc.), encrypt at rest.
Mixing with LDAP
Local users are ignored at login time when backend: ldap (a warning is logged at startup if both are populated). The two backends are mutually exclusive at runtime. For emergency local access while LDAP is the active backend, use Break-Glass Access instead — it has the audit treatment to match.
Common mistakes
- Plaintext password in
passwordHash. Verification will always fail. Hashes start with$argon2id$v=19$.... - Forgetting
roles: []. A user with no roles can authenticate but has no permissions — the UI shows “no access” for every protected feature. Always assign at leastviewer. - Same username appearing twice. First match wins; the second entry is ignored.