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:

  1. 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.
  2. If found, argon2.verify(passwordHash, typedPassword).
  3. On match, return { username, roles }.
  4. 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 .gitignore excludes horizon.yaml; only horizon.example.yaml is 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 least viewer.
  • Same username appearing twice. First match wins; the second entry is ignored.