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.
Tiers and credential resolution
Section titled “Tiers and credential resolution”Three tiers, in ascending privilege: anon → client → admin. On every
request, hal0 resolves the caller’s tier in this order:
- Session cookie (
hal0_session) — if present and valid, the caller is admin. A session always means “an operator’s own browser,” neverclient. Authorization: Bearer <key>— checked against the configured admin key first, then the client key.?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.
Enabling auth
Section titled “Enabling auth”A fresh install has no admin key and enforcement off. To lock it down:
- Get an admin key. Nothing generates one automatically — set
HAL0_ADMIN_KEYin/etc/hal0/api.envyourself, or rotate one into existence:This writes the new key straight toTerminal window hal0 auth rotate admin/etc/hal0/api.envand prints a fingerprint — never the key itself. Retrieve the value from that file. - Turn enforcement on:
Equivalent to
Terminal window hal0 auth require onPUT /api/auth/requirewith{"require_auth": true}. Takes effect on the very next request — no restart. The server refuses this call with400 auth.no_admin_keyif no admin key is set yet, so you can’t lock yourself out by skipping step 1. - 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.
Endpoints
Section titled “Endpoints”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 |
POST /api/auth/login
Section titled “POST /api/auth/login”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>}.
POST /api/auth/rotate
Section titled “POST /api/auth/rotate”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.
GET /api/auth/status
Section titled “GET /api/auth/status”{ "auth_required": true, "has_admin_key": true, "tier": "client" }Never returns secrets — safe for the dashboard to poll unauthenticated.
Sessions
Section titled “Sessions”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.
Rate limiting
Section titled “Rate limiting”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.
Origin protection
Section titled “Origin protection”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).
Route classification
Section titled “Route classification”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/modelsGET /v1/models/{model_id}GET /api/healthGET /api/health/systemGET /api/metrics/prometheusGET /api/config/urlsPOST /api/auth/loginGET /api/auth/statusPOST /api/auth/logoutInference (/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.
CLI reference
Section titled “CLI reference”hal0 auth status # GET /api/auth/statushal0 auth rotate admin|client # POST /api/auth/rotate (confirms unless --force)hal0 auth require on|off # PUT /api/auth/requireThe 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.
Config and environment variables
Section titled “Config and environment variables”| 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.
TLS still needs a proxy
Section titled “TLS still needs a proxy”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.
http: routers: hal0: rule: "Host(`hal0.example.com`)" service: hal0 tls: certResolver: letsencrypt services: hal0: loadBalancer: servers: - url: "http://127.0.0.1:8080"server { listen 443 ssl; server_name hal0.example.com;
ssl_certificate /etc/letsencrypt/live/hal0.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/hal0.example.com/privkey.pem;
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }}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.
The second listener: OpenWebUI on :3001
Section titled “The second listener: OpenWebUI on :3001”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:
HAL0_OWUI_BIND_HOST=127.0.0.1Trusted-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:
WEBUI_AUTH=TrueWEBUI_AUTH_TRUSTED_EMAIL_HEADER=X-Forwarded-Emailor, at install time, HAL0_OWUI_TRUSTED_EMAIL_HEADER=X-Forwarded-Email
in the installer’s environment.
See also
Section titled “See also”- Companion services —
/etc/hal0/api.env, the same file the admin/client keys live in, is alsohal0-api.service’s systemdEnvironmentFile.