Skip to content

Authentication

hal0 ships built-in login, sessions, and a deny-by-default route classifier (KB-1). There’s no per-user account system — just two credential tiers (admin, client) plus a browser session that’s always admin-equivalent — but every route in the API is classified and a caller without the right tier is rejected before the handler runs.

Three tiers, in ascending privilege: anonclientadmin. On every request, hal0 resolves the caller’s tier in this order:

  1. Session cookie (hal0_session) — if present and valid, the caller is admin. A session always means “an operator’s own browser,” never client.
  2. Authorization: Bearer <key> — checked against the configured admin key first, then the client key.
  3. ?api_key=<key> query param — same admin-then-client check, as a fallback for WebSocket upgrades, which can’t set headers.

There is no client-side login flow — the client tier is Bearer/api_key-only, meant for programmatic or embedded callers. Only an admin key or an already-authenticated browser can call /api/auth/login.

A fresh install has no admin key and enforcement off. To lock it down:

  1. Get an admin key. Nothing generates one automatically — set HAL0_ADMIN_KEY in /etc/hal0/api.env yourself, or rotate one into existence:
    Terminal window
    hal0 auth rotate admin
    This writes the new key straight to /etc/hal0/api.env and prints a fingerprint — never the key itself. Retrieve the value from that file.
  2. Turn enforcement on:
    Terminal window
    hal0 auth require on
    Equivalent to PUT /api/auth/require with {"require_auth": true}. Takes effect on the very next request — no restart. The server refuses this call with 400 auth.no_admin_key if no admin key is set yet, so you can’t lock yourself out by skipping step 1.
  3. Log in from the dashboard with the admin key, or use it directly as a Bearer token against /api/*.

To go back to open access: hal0 auth require off.

All under /api/auth:

Method & path Auth class Purpose
POST /api/auth/login open Validate a key, mint a session cookie
POST /api/auth/logout open Clear the session cookie
GET /api/auth/status open This caller’s resolved tier + posture
PUT /api/auth/require admin Turn enforcement on/off
POST /api/auth/rotate admin Rotate the admin or client key
GET /api/auth/exposure admin Dump the full route classification table

Body: {"key": "<candidate>"}. Rate-limited per caller IP before the key is even compared (constant-time hmac.compare_digest, so a blocked caller never reaches the check). On success, sets an HttpOnly session cookie and returns {"ok": true, "tier": "admin"}. On a bad key: 401, code: "auth.invalid_key". On a limiter trip: 429, code: "auth.rate_limited", details: {"retry_after_s": <int>}.

Body: {"tier": "admin" | "client"} (default "admin"). Same login-limiter gate as /login. Response is status-only — the key value is never returned or logged:

{
"tier": "admin",
"key_len": 43,
"fingerprint": "a1b2c3d4",
"rotated_at": "2026-07-31T12:00:00Z",
"applies_live": true,
"restart_required": false,
"session_preserved": true,
"note": "New admin key written to /etc/hal0/api.env — retrieve it there; it is never shown in the dashboard."
}

fingerprint is the first 8 hex characters of sha256(key) — enough to confirm which key is live without exposing it. An admin’s browser session survives a rotation (it’s HMAC-signed independently); any Bearer/api_key caller on the old key stops working immediately.

{ "auth_required": true, "has_admin_key": true, "tier": "client" }

Never returns secrets — safe for the dashboard to poll unauthenticated.

The session cookie (hal0_session) is <payload>.<hmac>, base64url-encoded: payload is {session_id, expires_at}, signature is HMAC-SHA256(secret, payload). TTL is 8 hours. The signing secret is a 32-byte random value generated on first use at /var/lib/hal0/agents/secret.bin (mode 0600) and never leaves the hal0 service user. The cookie is set HttpOnly, SameSite=Lax.

POST /api/auth/login and POST /api/auth/rotate share one sliding-window limiter, keyed per caller IP: 10 events / 60s by default. A blocked caller isn’t recorded again while blocked, so hammering the endpoint doesn’t extend its own lockout. Tune with HAL0_LOGIN_RATELIMIT_MAX and HAL0_LOGIN_RATELIMIT_WINDOW_S.

By default the raw TCP peer is used as the rate-limit key. Behind a reverse proxy, set HAL0_TRUST_FORWARDED_FOR=true (or persist [security].trust_forwarded_for via the Settings page) so the limiter reads the real client IP from X-Forwarded-For instead of the proxy’s own address — only do this once you’ve confirmed your proxy strips/overwrites any client-supplied X-Forwarded-For (nginx/Traefik/Caddy do this by default); otherwise a caller can pick its own rate-limit bucket by forging the header.

For state-changing HTTP methods (POST/PUT/PATCH/DELETE) and all WebSocket upgrades, hal0 checks the Origin header before tier auth even runs: no header → allowed, origin in HAL0_ALLOWED_ORIGINS → allowed, origin’s host matches the request Host (same-origin) → allowed, otherwise 403 auth.origin_forbidden (or WS close code 4403).

Every route falls into one of four classes — open (no auth), bootstrap (open until an admin key exists, then treated as admin), client, or admin. An unclassified path defaults to admin — deny-by-default, not allow-by-default. The full live table is served at GET /api/auth/exposure (admin-gated) and backs the dashboard’s Settings → Security page.

The open allowlist is intentionally small — model listing, health, metrics, and the login/logout/status routes themselves:

GET /v1/models
GET /v1/models/{model_id}
GET /api/health
GET /api/health/system
GET /api/metrics/prometheus
GET /api/config/urls
POST /api/auth/login
GET /api/auth/status
POST /api/auth/logout

Inference (/v1/*) and read-only status/list routes are client-tier. Anything that mutates config, secrets, memory, or services is admin-tier. A hardened subset of memory-delete routes is pinned to admin regardless of how the generic /api/memory rule is ever widened, following an earlier incident where an unauthenticated bulk-delete call cascaded through memory records.

Terminal window
hal0 auth status # GET /api/auth/status
hal0 auth rotate admin|client # POST /api/auth/rotate (confirms unless --force)
hal0 auth require on|off # PUT /api/auth/require

The CLI itself resolves credentials the same way: HAL0_ADMIN_KEY env, then HAL0_CLIENT_KEY env, then whatever’s in /etc/hal0/api.env on disk.

Variable Purpose Default
HAL0_ADMIN_KEY Admin-tier credential; also required before require_auth can be turned on unset
HAL0_CLIENT_KEY Client-tier credential (no login flow — Bearer/api_key only) unset
HAL0_REQUIRE_AUTH Runtime override for enforcement (1/true/yes/on, 0/false/no/off) unset (falls through to persisted config, then off)
HAL0_TRUST_FORWARDED_FOR Trust X-Forwarded-For for the login rate-limit key unset (off)
HAL0_ALLOWED_ORIGINS Browser Origin allowlist for state-changing requests and WS upgrades unset
HAL0_LOGIN_RATELIMIT_MAX Login/rotate limiter budget 10
HAL0_LOGIN_RATELIMIT_WINDOW_S Login/rotate limiter window, seconds 60.0

Both HAL0_ADMIN_KEY/HAL0_CLIENT_KEY live in /etc/hal0/api.env (mode 0600, loaded by hal0-api.service as a systemd EnvironmentFile). A key rotation updates that file and the running process’s environment in place — no restart. require_auth/trust_forwarded_for can instead be persisted to hal0.toml’s [security] table (what the Settings page writes); the env var, when set, always wins over the persisted value.

hal0 doesn’t terminate TLS or manage certificates — the admin-key/session model above governs who can call the API, not how the connection is encrypted. If you’re reaching hal0 over anything other than a trusted LAN, put a reverse proxy in front that owns TLS:

hal0.example.com {
reverse_proxy 127.0.0.1:8080
}

Caddy obtains and renews the certificate automatically via ACME as long as port 80/443 are reachable for the challenge.

If your proxy forwards X-Forwarded-For, set HAL0_TRUST_FORWARDED_FOR=true (see Rate limiting) once you’ve confirmed the proxy itself strips any client-supplied value — otherwise treat hal0’s own admin-key auth as the access control and skip proxy-level basic auth entirely.

If hal0-openwebui is installed, it publishes a complete chat UI on port 3001 with WEBUI_AUTH=False — no login page. Anyone who can reach it can talk to your models and read every stored conversation (/var/lib/hal0/openwebui). It sits entirely outside hal0’s own auth model: enabling enforcement and hardening :8080, then stopping there, leaves it open.

Two knobs, both in /etc/hal0/openwebui.env. Hand edits to that file are preserved across install and upgrade runs, so editing it is a supported way to set them; systemctl restart hal0-openwebui to apply.

Bind address. HAL0_OWUI_BIND_HOST is what the unit publishes on. The installer seeds it from the same HAL0_BIND_HOST choice that drives hal0-api, so binding hal0 to loopback takes both surfaces off the LAN:

/etc/hal0/openwebui.env
HAL0_OWUI_BIND_HOST=127.0.0.1

Trusted-header SSO. If your proxy authenticates users and injects their address as a header, OpenWebUI can consume it. Set the header name — that alone turns its auth on and wires it up:

/etc/hal0/openwebui.env
WEBUI_AUTH=True
WEBUI_AUTH_TRUSTED_EMAIL_HEADER=X-Forwarded-Email

or, at install time, HAL0_OWUI_TRUSTED_EMAIL_HEADER=X-Forwarded-Email in the installer’s environment.

  • Companion services/etc/hal0/api.env, the same file the admin/client keys live in, is also hal0-api.service’s systemd EnvironmentFile.