Security posture

hal0’s security posture has two layers, and it’s important not to conflate
them: a default posture (open on your LAN, no auth enforced) that hasn’t
changed, and a v1.0 auth layer that’s new — real, code-complete, and
opt-in. If you last read hal0’s security docs before v1.0, the headline
correction is this: hal0 is no longer “no built-in auth, full stop.” It now
ships one, off by default, one config write away from on.

The default posture: open, on purpose

The API binds 0.0.0.0:8080 and, out of the box, every endpoint is reachable
by anything that can reach that port — no login screen, no key required.
This was a deliberate call: an earlier design bundled an auth layer and a
TLS-terminating proxy, and that was removed because it duplicated, badly,
what every operator already runs better elsewhere (a real reverse proxy).
The shipped default is still OFF — turning on the layer below is an
explicit operator decision, not something that silently activates.

One exception since v1.3.0: once an admin key is configured, a box bound past
loopback gates its ADMIN-class routes (every mutation, plus a few
config-revealing reads) for callers that are not on loopback, even with
enforcement off. Reads, inference, and anything arriving over loopback stay
open; a box with no admin key stays fully open. See
Authentication for the exact rule.

The v1.0 auth layer: three tiers, deny-by-default

When you do turn it on, hal0 resolves every request to one of three
principal tiers via AuthPrincipal, plus a fourth for the agent bridge that
is enforced whether auth is on or not:

Tier What it grants
anon No credential presented, or none configured to check against.
client A valid client key — access to the inference surface (/v1/*) and a short, explicit list of read-only introspection routes.
admin A valid admin key, or a verified dashboard session cookie — full control plane access.
bridge A remote coding agent’s own key (hal0bk_…, minted by hal0 bridge add; ADR-0043) — the bridge’s own surfaces and the MCP mounts its scopes name, and no other non-OPEN route. Required even with auth off.

Credentials are resolved in a fixed priority order: session cookie →
Authorization: Bearer header → ?api_key= query param
(the query-param
fallback exists because a browser can’t set a custom header on a WebSocket
upgrade). An admin key presented anywhere a client key would be accepted
still resolves to the more powerful admin tier, rather than being rejected
as “the wrong kind of key.” Key comparison is constant-time
(hmac.compare_digest) to avoid a timing side-channel; bridge keys are the
exception, matched by a sha256 lookup rather than a compare, since the hash of a
guessed key tells a timing observer nothing about the stored one.

Every route is pre-classified — deny-by-default

Underneath the principal check is a route exposure table
(AuthClass: OPEN | CLIENT | ADMIN | BOOTSTRAP | BRIDGE), evaluated
first-match-wins per request path:

  • OPEN — a deliberately tiny, enumerated set: the /v1/models SDK
    probe, the Prometheus scrape endpoint, liveness checks the installer and
    systemd watchdog hit before any credential exists, and the static SPA
    shell (same bundle for every visitor, carries no server data).
  • BOOTSTRAP — open only until an admin key is configured (the
    installer surface); once HAL0_ADMIN_KEY is set, these routes behave
    exactly like ADMIN. This three-state design (unconfigured / configured /
    dev-open) exists specifically to avoid a first-run chicken-and-egg lockout.
  • CLIENT — the inference surface plus a short list of genuinely
    read-only introspection GETs.
  • BRIDGE — the agent bridge’s /mcp/agents mount and
    GET /api/bridge/me: a bridge key or the admin key, and never the dev-open
    bypass.
  • ADMIN — everything else, including every mutating route and every
    route that can return secrets or config. Unclassified paths fall back to
    ADMIN.
    A newly added router is locked out until a rule is added for it —
    that’s the whole point of the ratchet, and it’s backed by a CI test
    (tests/security/test_exposure.py) so a route can’t quietly ship
    unclassified.

Turning it on

require_auth_enabled() resolves the posture in this precedence order:

  1. HAL0_REQUIRE_AUTH environment variable — an explicit runtime override, highest priority.
  2. The persisted [security].require_auth config toggle — what the dashboard’s Security page writes via PUT /api/auth/require. Picked up on the very next request, no restart needed.
  3. OFF — the shipped default. hal0 runs trusted-LAN-open unless you explicitly enable auth.

Once enabled: configure HAL0_ADMIN_KEY (and optionally HAL0_CLIENT_KEY
for a lower-privilege credential) as environment variables for the hal0-api
service, and present the key via the dashboard login, a Bearer header, or
?api_key=.

The reverse-proxy pattern still applies

Even with auth enabled, hal0’s auth layer is not a TLS terminator and
doesn’t replace a real edge proxy. For anything beyond a single trusted LAN,
put a reverse proxy (Traefik, nginx) in front of hal0-api:

  1. TLS termination — present a certificate for your chosen hostname and
    terminate HTTPS at the proxy.

  2. Additional authentication, if you want it (OAuth/OIDC, mTLS, an
    allowlist) enforced before a request is even forwarded — on top of, not
    instead of, hal0’s own auth tier if you have both enabled.

  3. Forwarding — proxy the request to hal0-api on 127.0.0.1:8080 (or its
    LAN address), and bind hal0 itself so only the proxy can reach it.

MCP host and origin allowlists

The MCP mount enforces a separate network gate. The admin and memory MCP
servers are mounted as sub-applications under /mcp/admin and /mcp/memory
(and /mcp/agents while the agent bridge is on), and the MCP transport applies
DNS-rebinding protection.


The dashboard’s MCP tab displays the live admin and memory server status.

By default the allowlist holds the names this box answers to: loopback
(127.0.0.1, localhost and [::1], any port), every detected LAN IP, the
hostname, its .local mDNS name, and the host of [network].public_url, each
bare and with the API port (HAL0_PORT, default 8080). A bridge client on
another machine reaches /mcp/* by any of them with no setup. The box’s own
names loosen nothing, because DNS rebinding sends a name the attacker controls.
Any other Host gets a bare 421 Invalid Host header response. The set is read
when hal0-api starts, so a new LAN IP, hostname or public_url needs a restart.
To reach /mcp/* by another name, such as a reverse-proxy domain, add it:

  • HAL0_MCP_ALLOWED_HOSTS — a comma-separated list of host,
    host:port, or host:* values added to the default allowlist. The single
    value * disables DNS-rebinding protection entirely (the fully-open posture
    some LAN-only deployments want).
  • HAL0_MCP_ALLOWED_ORIGINS — a comma-separated list of browser origins.
    When left unset, http and https origins are derived automatically from
    each host you add, so the dashboard and a reverse-proxy vhost work without a
    second knob.

This is a host/origin gate, not the auth layer above — it stops a browser on
another site from rebinding DNS to reach your loopback MCP server. It composes
with the auth tiers, not instead of them.

Browser sessions: origin allowlist + session cookie

When the key gate is enforced, every WebSocket upgrade and every
state-changing request that carries an Origin header is checked against an
origin allowlist (HAL0_ALLOWED_ORIGINS, comma-separated; the default
covers http://hal0.local, http://localhost:5173, http://localhost:8080,
and http://127.0.0.1:8080; the installer replaces it with a host-derived
list). A dashboard sign-in (POST /api/auth/login) mints an HMAC-SHA256
session cookie
(hal0_session, HttpOnly, SameSite=Lax) that the
middleware treats as the admin tier: one signing secret
(/var/lib/hal0/agents/secret.bin, mode 0600), one cookie, one place it’s
verified. See Authentication. Caller identity still
flows on X-hal0-Agent underneath.

Credentials are re-checked while a connection is open, not only when it starts.
Rotating a key closes the WebSockets that were using the old credential with
code 4401 and ends open Server-Sent Events streams with a final
event: revoked frame. A sweep every 30 seconds does the same for expired
sessions (8 hours, or 30 days with Remember me). Details: Revoked streams.

Agent identity and gated actions

hal0’s agent and memory surfaces resolve caller identity from the
X-hal0-Agent request header (validated to a bounded [a-zA-Z0-9_-] id),
and the same identity resolves the same memory namespace whether it arrives
over REST or MCP. Privileged or destructive agent actions are gated:
rather than executing immediately, they enqueue an approval that a human
clears from the dashboard, the CLI, or via the approval API. See
Agents for how personas, tool gating, and the
approval queue fit together.

Login attempts are also rate-limited (a login-throttle mechanism in
hal0.security.ratelimit) so a brute-force key guess against an enabled auth
layer doesn’t get unlimited attempts.

Practical checklist

  • Keep it off the internet — Never bind the open API where untrusted clients can reach it, auth enabled or not. Front it with a proxy or keep it LAN-only.
  • Turn auth on if the LAN isn’t fully trusted — Set HAL0_ADMIN_KEY and flip [security].require_auth (or HAL0_REQUIRE_AUTH=1) — it’s off by default.
  • Terminate TLS at a proxy — Add HTTPS at Traefik / nginx regardless of whether hal0’s own auth is on.
  • Scope the MCP allowlist — Widen HAL0_MCP_ALLOWED_HOSTS only to the hosts that must reach /mcp/*; avoid * unless the whole LAN is trusted.

Where to go next

  • Architecture: How the API, slots, and dispatcher fit together.
  • Agents: Personas, tool gating, and the approval queue.
  • Memory: The opt-in memory subsystem and its namespace grammar.