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
Three tiers, in ascending privilege: anon → client → admin, plus
bridge, a remote coding agent’s own key
(ADR-0043). 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,” never
client. Authorization: Bearer <key>— ahal0bk_key of a live bridge
client is bridge; any other key is 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. Never a bridge key.
A bridge caller reaches /mcp/agents, GET /api/bridge/me and the MCP
mounts its scopes name, and gets 403 on every other non-OPEN route, with
enforcement on or off; see
Connect an external MCP client.
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
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 tohal0 auth rotate admin/etc/hal0/api.envand prints a
fingerprint — never the key itself. Retrieve the value from that file,
or runsudo hal0 auth reset-keyon the box instead, which mints the key
and prints it once (see the CLI reference below). - Turn enforcement on:
Equivalent tohal0 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. On a box bound past
loopback that still has an admin key, this does not re-open ADMIN-class
routes to off-box callers — see the next section.
ComfyUI’s GPU gate credential
ComfyUI’s GPU gate node calls three ADMIN routes before every image prompt
(and reports a prompt it refused to a fourth), so with enforcement on it needs
a credential. So does a box with enforcement
off whose HAL0_BIND_HOST is a specific address and that has an admin key:
the gate then calls that address, not loopback, and the ADMIN rule in the next
section applies to it. The gate never holds the admin key. hal0 mints it a
token of its own:
- Where it lives.
/var/lib/hal0/comfyui-gate/token, mode0600in a
0700directory, both owned by the service user. hal0 writes it the first
time theimgslot starts and mounts the directory read-only into the
ComfyUI container. The token is never in the unit file or the container’s
environment. - What it opens. Only the gate’s four requests:
GET /api/comfyui/arbiter,POST /api/comfyui/prompt-handoff,POST /api/comfyui/switchoverasking for image mode withoutforceorpin
(anything else on that route gets403 auth.forbidden), andPOST /api/comfyui/gate-refusal(the reason it refused a prompt, kept to 500
characters and reported onGET /api/comfyui/status), and only from the
box itself: loopback, or the addresshal0-apibinds. It opens no other
route, no WebSocket and no stream. - When the gate has none. A container started before the token existed,
such as one still running from before the update, sends no credential. On a
box that needs one (enforcement on, or the LAN rule in the next section), the
1.6.x gate such a container runs lets every prompt through unguarded, and a
1.7 gate started without its token refuses every image prompt at once with a
message that says so (unlessHAL0_COMFYUI_GATE_FAIL_OPEN=1lets it through,
see below); either way, runhal0 slot restart img. Elsewhere its requests pass without a credential, as
before. The gate also holds the token back when
HAL0_BIND_HOSTis a host name rather than an address (it speaks plain
HTTP, and a name is resolved on every request), so set an address there. - Replacing it. Delete the token file, then run
hal0 slot restart img;
hal0 mints a new one.
HAL0_COMFYUI_GATE_FAIL_OPEN=1 still lets prompts through unguarded, as in
v1.6.5, when hal0 refuses the gate’s credential; a switch or prompt that hal0
answered and refused stays refused. See
ComfyUI’s editor and the GPU gate.
ADMIN routes on a LAN-bound box
Since v1.3.0, require_auth = false no longer waves every request through.
An ADMIN-class request is enforced exactly as if auth were on whenever all
three of these hold:
- an admin key is configured (
HAL0_ADMIN_KEY); - the API listener is not loopback-bound —
HAL0_BIND_HOSTis anything
other than127.0.0.1,localhostor::1(the installer seeds
0.0.0.0); and - this request’s own peer did not arrive over loopback.
Such a request with no admin session cookie, Bearer key or ?api_key= gets
401 auth.required; one carrying the client key gets 403 auth.forbidden; a
WebSocket upgrade is closed with code 4403. The Origin check described in
Origin protection below also runs on these requests. OPEN and CLIENT
routes — /v1 inference and the read-only status/list routes — stay open, and
anything arriving over loopback (the CLI, on-box services, the operator at the
console) is unaffected.
Deciding an approval is stricter: see
who can decide.
A box with no admin key is not covered: the requirement would be
unsatisfiable, the same first-run carve-out the bootstrap class makes, so
the keyless default stays open to the LAN. hal0 doctor all reports both
LAN-reachable, auth-off postures as a warning on its auth row rather than a
pass.
The rule is per route class, not per HTTP method. ADMIN is the control
plane: changes to the box (model pulls, slot and profile edits, settings and
secrets writes, approvals, updates), and also the reads that can expose
configuration or box activity — settings, secrets, memory, logs, the activity
and event streams, approvals, services and updates. It is not every
state-changing request: the /v1 inference surface and the /mcp/memory
tool mount are CLIENT for all methods, so with enforcement off they stay open
to the LAN in this posture. An off-box browser with no session can therefore
list slots, models and hardware, but most dashboard pages have nothing to
show it.
The dashboard therefore asks for the admin key at the front door in this
posture: a browser whose ADMIN requests would be refused gets the login screen
on load, worded for this case (“This hal0 is reachable from your network”)
rather than as if enforcement were on. The login is asked for on every page.
View this page read-only dismisses it for the page you are on (a
top-level section such as Slots, with its tabs) and shows what is open there;
moving to another section asks again; a Sign in chip in the top bar then
opens the admin-key drawer at any time, and a change attempted while
read-only opens the same drawer and retries once you sign in. Signed in, the
chip reads Admin with a Log out button. When the session
lapses, the next refused request brings the login screen back. Whenever the
login screen appears, whether after a log-out or a lapse, the dashboard
discards what it had fetched under the previous session, so none of it is
readable through the read-only view. A browser on
the box itself (loopback), or any browser on a box with no admin key, sees
none of this. From other clients, send the admin key on the request. To make the
posture explicit, run hal0 auth require on; to take the box off the LAN
instead, set HAL0_BIND_HOST=127.0.0.1 in /etc/hal0/api.env and restart
hal0-api (the value sits in the installer-managed hal0-network block, which
a later install.sh run rewrites).
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
Body: {"key": "<candidate>", "remember": false}; remember is optional.
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", "session_ttl_s": 28800}. With
"remember": true the session lasts 30 days instead of 8 hours and
session_ttl_s is 2592000 (see Sessions below). 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
Body: {"tier": "admin" | "client", "keep_sessions": bool} (defaults
"admin" and false). 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": false,
"note": "New admin key written to /etc/hal0/api.env — retrieve it there; it is never shown in the dashboard. Bearer/API clients must switch to the new key. Other sessions were signed out; this browser stays signed in."
}
fingerprint is the first 8 hex characters of sha256(key) — enough to
confirm which key is live without exposing it. Any Bearer/api_key caller on
the old key is refused from its next request, and the rotation closes any
WebSocket it already has open (for example /v1/realtime) with code 4401,
and ends any streaming response it already has open (for example
/api/events/stream, or a streamed /v1/chat/completions). See
Revoked streams.
An admin rotation also signs out every browser session: it replaces the
session signing secret, so every existing hal0_session cookie stops
verifying (see Sessions), and every WebSocket opened with one of
those cookies (for example the board’s /api/board/events feed) is closed
with code 4401, and every stream opened with one (for example
/api/events/stream) is ended. The browser
that made the call is not locked out. If it sent a valid session, the same response sets a fresh cookie
that expires no later than the one it replaced. A Bearer caller gets no cookie.
Send "keep_sessions": true to leave other sessions signed in, for routine
hygiene when you do not suspect the old key leaked. session_preserved says
which happened: false when sessions were signed out, true with
keep_sessions or for a client-key rotation, which never touches sessions.
GET /api/auth/status
{
"auth_required": false,
"has_admin_key": true,
"lan_exposed": true,
"admin_gated": true,
"admin_sign_in_required": true,
"tier": "anon"
}
lan_exposed is true when the API is bound beyond loopback, independent of
auth_required.
The last two fields are verdicts for the caller that asked, because the
LAN-bound rule above depends on that request’s own peer:
admin_gated— are ADMIN-class requests from you enforced at all? True when
enforcement is on, or the LAN-bound rule applies to your peer. It does not
change when you sign in.admin_sign_in_required— would one be refused right now? True when
admin_gatedis true and you hold no admin session or key.
The dashboard shows its login screen on admin_sign_in_required, and its
Admin / Log out chip only for an admin session where admin_gated is true.
A browser on the box itself reads false for both, whatever cookie it holds.
The example above is an off-box browser on a LAN-bound box with enforcement
off.
Never returns secrets — safe for the dashboard to poll unauthenticated.
Sessions
The session cookie (hal0_session) is <payload>.<hmac>, base64url-encoded:
payload is {session_id, expires_at}, signature is
HMAC-SHA256(secret, payload). 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, and Secure whenever the login arrived over TLS — directly,
or through a reverse proxy that sets X-Forwarded-Proto: https — so a browser
that reached hal0 over HTTPS never sends the session in cleartext. A plain-HTTP
install (the bare :8080 listener) gets a cookie without Secure, since the
browser could not use one otherwise.
A session lasts 8 hours by default. Ticking Remember me on the login
screen ("remember": true on POST /api/auth/login) makes it 30 days.
The expiry is inside the signed payload, so a browser cannot extend its own
session, and the cookie’s Max-Age is set to match. The dashboard remembers
only whether the box was ticked last time, never the key.
Sessions are not stored server-side, so one cannot be revoked on its own.
Rotating the admin key ends every session at once, for example after
signing in with Remember me on a machine you no longer trust:
hal0 auth rotate admin # or: sudo hal0 auth reset-key, or Rotate in Settings
The rotation replaces the signing secret, so every existing cookie stops
verifying on its next request, with no restart. WebSockets and streams are
checked only when they open, so the rotation also closes every open socket
whose cookie or key no longer verifies (close code 4401) and ends every open
stream in the same position. The browser you rotate
from stays signed in. Pass --keep-sessions ("keep_sessions": true in the API) to
rotate the key without signing anyone out.
WebSocket close code 4401
4401 (reason credential revoked) means the credential the socket was
opened with no longer grants access. A client should not retry with the same
credential: sign in again (or switch to the new key), then reconnect. A
reconnect with a cookie that no longer verifies is refused at the upgrade
with 4403.
hal0 has two WebSockets, the board feed (/api/board/events) and
/v1/realtime. Both are re-checked when a key is rotated through the running
API, and every 30 seconds while they are open, so a socket opened with a
session cookie that later expires is closed with 4401 within 30 seconds
of the expiry. One case does not close an open socket:
- The secret is replaced outside the API.
sudo hal0 auth reset-key
writes the new secret and key itself when its call to the running API
fails. Open WebSockets and streams then stay open until the
systemctl restart hal0-apithe command asks for.
Revoked streams
A streaming response (the text/event-stream feeds such as
/api/events/stream, /api/journal/stream and /api/activity/stream, and streamed
/v1 completions) follows the same rule as a WebSocket: it is re-checked on a
rotation and every 30 seconds, and ends once its cookie, bearer or api_key
would no longer be let in. Nothing more the server produces reaches the
client. A text/event-stream response ends with one final frame:
event: revoked
data: {"reason": "credential revoked"}
It is a named event, so an EventSource onmessage handler does not see it;
listen with addEventListener("revoked", ...) to tell a sign-out from a
dropped connection. A reconnect with the old credential is refused with 401.
Only text/event-stream responses and streamed /v1 relays (whatever content
type the upstream sends) are re-checked this way; a relay that is not
text/event-stream just ends, with no final frame. Any other response is
short and finishes on its own. Like sockets, streams are only re-checked
while auth is enforced for the caller: with auth off nothing is revoked.
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
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 the allowlist (HAL0_ALLOWED_ORIGINS
plus hal0.toml’s [network] origins) → allowed,
origin equals the request’s own origin and the Host is an IP address
(v4 or v6) → allowed, otherwise 403 auth.origin_forbidden (or WS close code
4403).
A named Host (a DNS name, hal0.local, the box hostname) only passes
through the allowlist. A matching Origin/Host pair on a DNS name proves
nothing: a page on an attacker’s domain can rebind that name to the box’s IP
and send both headers itself (DNS rebinding). The installer seeds
HAL0_ALLOWED_ORIGINS with localhost, 127.0.0.1, every detected LAN IP,
the box hostname (plus its .local form), and hal0.local, all on the API
port. If you reach hal0 by any other name, such as a reverse proxy’s domain
or a router-assigned box.lan, allow its origin with
hal0 network public-url set or hal0 network origins add; see
Allow the public URL. Until you do, the browser gets
403 auth.origin_forbidden, and the API log records the refused origin
under hal0.auth.origin_rejected.
Route classification
Every route falls into one of five classes — open (no auth), bootstrap
(open until an admin key exists, then treated as admin), client,
bridge (/mcp/agents and GET /api/bridge/me, enforced even with auth
off), 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.
CLI reference
hal0 auth status # GET /api/auth/status
hal0 auth rotate admin|client # POST /api/auth/rotate (confirms unless --force;
# admin signs out other sessions unless --keep-sessions)
hal0 auth require on|off # PUT /api/auth/require
sudo hal0 auth reset-key # mint a new admin key and print it once
sudo hal0 auth reset-key is the lost-key recovery path. It must run on the
box as root, because the key lives in the root-only /etc/hal0/api.env. When
hal0-api is up it rotates through POST /api/auth/rotate, so the new key
applies live; when the API is down it writes api.env directly and the key
applies after sudo systemctl restart hal0-api. Either way it prints the new
key once. Callers still on the old key stop working, and every signed-in
browser session is signed out unless you pass --keep-sessions; the output
says which. On a box with no admin key yet, this creates the
first one, which turns on the ADMIN-route rule for a LAN-bound box described
above.
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.
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.
HAL0_ALLOWED_ORIGINS is the one exception to “the env var wins”: the
installer manages it, and hal0 adds hal0.toml’s [network].public_url
origin and [network].extra_origins on top of it rather than replacing it.
See Allow the public URL.
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:
Caddy
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.
Traefik
http:
routers:
hal0:
rule: "Host(`hal0.example.com`)"
service: hal0
tls:
certResolver: letsencrypt
services:
hal0:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
nginx
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 the Rate limiting section above) 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.
Allow the public URL
A browser on https://hal0.example.com sends that as its Origin, and the
origin check refuses state-changing requests and
WebSocket upgrades from an origin it doesn’t know. Tell hal0 the URL once:
hal0 network public-url set https://hal0.example.com
This writes public_url to hal0.toml’s [network] table (Settings ▸
Network writes the same key). Its origin joins the allowlist on
the next request, with no restart. For any other name you reach hal0 by,
such as a second proxy, a MagicDNS name or http://box.lan:8080, add the
origin itself:
hal0 network origins add http://box.lan:8080
hal0 network origins list
Each entry is scheme://host[:port] with no path. Hosts must be a DNS name,
an IPv4 address or a bracketed IPv6 address; an internationalised name is
stored in its punycode form, which is what browsers send. hal0 rejects
anything else when you save it. hal0 network origins remove <origin> and
hal0 network public-url clear revoke an origin the same way, on the next
request.
This is the one way to allow an extra origin. Don’t add origins to
HAL0_ALLOWED_ORIGINS in /etc/hal0/api.env by hand. That line sits inside
the installer-managed block, which every installer run rewrites, so a
hand-added origin disappears on the next re-run. The [network] keys sit in
hal0.toml and survive it, because hal0 adds them on top of
HAL0_ALLOWED_ORIGINS at runtime. An installer run with HAL0_PUBLIC_URL set
saves the URL as public_url if one isn’t set yet. It never replaces one that
is.
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:
# /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.
See also
- Companion services —
/etc/hal0/api.env, the
same file the admin/client keys live in, is alsohal0-api.service’s
systemdEnvironmentFile.