Skip to content

Authentication & HTTPS

By default, hal0 binds the API on :8080 and OpenWebUI on :3001 with no authentication — a home-appliance posture for a fully-trusted LAN. To safely expose hal0 beyond that, the installer ships an opt-in auth mode that brings up a Caddy reverse proxy with HTTPS, basic_auth at the edge for the dashboard, and bearer-token auth for the OpenAI-compatible API.

Terminal window
sudo bash installer/install.sh --auth=basic

The installer prompts for an admin username and password. After it finishes:

  • Dashboardhttps://hal0.local/ (basic_auth prompt)
  • Chathttps://hal0.local/chat/ (single-sign-on, no second login)
  • APIhttps://hal0.local/v1/... (bearer token in Authorization header)

Non-interactive (CI / config management):

Terminal window
HAL0_ADMIN_USER=alex HAL0_ADMIN_PASSWORD='hunter2' \
HAL0_HOSTNAME=hal0.local \
sudo bash installer/install.sh --auth=basic
  1. Installs Caddy via the system package manager — apt install caddy on Debian/Ubuntu, pacman -S caddy on Arch/CachyOS. On distros without a packaged Caddy, the script surfaces a clear error and points at upstream install docs.

  2. Renders /etc/hal0/Caddyfile from the bundled template, baking in the admin username and a bcrypt password hash (generated by caddy hash-password). The plaintext password is never persisted.

  3. Drops hal0-caddy.service into /etc/systemd/system/ and starts it. Caddy listens on :80 (HTTP→HTTPS redirect) and :443 (the real surface).

  4. Flips HAL0_AUTH_ENABLED=1 in /etc/hal0/api.env and re-renders /etc/hal0/openwebui.env so OpenWebUI auto-provisions a user from the Caddy-forwarded identity (no second login).

  5. Restarts hal0-api and hal0-openwebui so the new env takes effect.

  6. Drops /etc/avahi/services/hal0.service if avahi-daemon is on the host so hal0.local resolves on the LAN. Without avahi, add a static /etc/hosts entry on each client: <hal0-ip> hal0.local.

Two surfaces share one identity model, gated by HAL0_AUTH_ENABLED.

You hit https://hal0.local/:

  1. Caddy challenges with HTTP basic_auth. You enter the admin username/password the installer prompted for. Caddy verifies against the bcrypt hash in /etc/hal0/Caddyfile.

  2. Caddy proxies to 127.0.0.1:8080 and adds X-Forwarded-Email: <username> to the request.

  3. hal0-api sees the forwarded header, trusts it (Caddy strips any inbound copy first to prevent spoofing), and treats you as scope=admin.

  4. For /chat/, Caddy proxies to OpenWebUI :3001 with the same forwarded header. OpenWebUI’s WEBUI_AUTH_TRUSTED_EMAIL_HEADER auto-provisions a user — no second login.

Your OpenAI client hits https://hal0.local/v1/chat/completions:

  1. The /v1/* Caddy block has no basic_auth. Request passes through to hal0-api.

  2. hal0-api reads Authorization: Bearer hal0_<id>.<secret>. The id half indexes into /etc/hal0/tokens.toml; the secret half is verified against the row’s argon2id hash.

  3. Match → request proceeds with the token’s label as identity and its scope. No match → 401 auth.invalid. Missing header → 401 auth.required.

/v1/models is on the public allowlist — many OpenAI clients probe it before authenticating, so it returns 200 without a token.

If a request carries both Authorization: Bearer ... and X-Forwarded-Email, the bearer wins. A bad bearer 401s even if the forwarded email is valid — you can’t downgrade auth by sending a broken token.

Caddy handles TLS termination automatically. The behaviour depends on the hostname you gave the installer (HAL0_HOSTNAME, default hal0.local).

The Caddyfile includes tls internal. Caddy mints a real TLS certificate from its own internal CA. Browsers see a valid cert chain, but the root isn’t in any public trust store, so you’ll get a “not trusted” warning until you import Caddy’s root cert.

The cert lives at:

/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt

Trust it on each client:

Terminal window
scp root@hal0.local:/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt /tmp/
sudo cp /tmp/root.crt /usr/local/share/ca-certificates/caddy-hal0.crt
sudo update-ca-certificates

After that, https://hal0.local/ is green-lock in every browser. Caddy auto-rotates the internal CA, so this is a one-time step per client.

For an internet-facing hostname (e.g. hal0.example.com), Caddy will run ACME against Let’s Encrypt automatically. Two ways to get there:

At install time — pass a real hostname:

Terminal window
HAL0_HOSTNAME=hal0.example.com HAL0_TLS_EMAIL=you@example.com \
HAL0_ADMIN_USER=alex HAL0_ADMIN_PASSWORD='hunter2' \
sudo bash installer/install.sh --auth=basic

Then edit the Caddyfile to remove tls internal:

Terminal window
sudo sed -i '/tls internal/d' /etc/hal0/Caddyfile
sudo systemctl reload hal0-caddy

Switching from internal CA to ACME later — same edit, no reinstall:

Terminal window
sudo sed -i 's/^\(\s*\){\$HAL0_HOSTNAME:hal0.local}/\1hal0.example.com/' /etc/hal0/Caddyfile
sudo sed -i '/tls internal/d' /etc/hal0/Caddyfile
sudo systemctl reload hal0-caddy

That’s it. Caddy will request, install, and renew the certificate; OCSP-staple it; and reload itself before each renewal. Requirements:

  • The hostname must resolve from the public internet to your hal0 box.
  • Port :80 must be reachable from the internet (Let’s Encrypt uses the http-01 challenge by default).
  • HAL0_TLS_EMAIL must be a real address — it’s the ACME contact for expiry warnings.

If port :80 can’t be opened (NAT, firewall policy, or you’d rather not expose anything other than :443), use the DNS-01 challenge instead — add a tls { dns <provider> ... } block to /etc/hal0/Caddyfile. Caddy supports dozens of DNS providers via build-time modules; see the Caddy docs for the list and configuration.

  • No certbot install, no cron jobs for renewal.
  • No nginx ssl_certificate paths to wire up.
  • No TLS cipher tuning — Caddy ships defaults that score A on SSL Labs.
  • No worrying about expiry — Caddy renews 30 days before expiration automatically and reloads itself.

Two ways:

Via the dashboard — Settings → Authentication → Create token. The raw value is shown once in a copy-once modal; the dashboard warns you it can’t be recovered afterwards.

Via the API (using Caddy basic_auth as the admin credential):

Terminal window
curl -k -u 'admin:hunter2' https://hal0.local/api/auth/tokens \
-H 'Content-Type: application/json' \
-d '{"label": "openwebui-bridge", "scope": "all"}'

Response (the token field is the only chance to capture the secret):

{
"id": "a1b2c3d4",
"label": "openwebui-bridge",
"scope": "all",
"created_at": "2026-05-15T12:00:00Z",
"token": "hal0_a1b2c3d4.<43-char-secret>",
"warning": "This token is shown once and cannot be retrieved later. Copy it now and store it in your secret manager."
}
Terminal window
curl -k https://hal0.local/v1/chat/completions \
-H 'Authorization: Bearer hal0_a1b2c3d4.<secret>' \
-H 'Content-Type: application/json' \
-d '{"model": "primary", "messages": [{"role": "user", "content": "hi"}]}'
Terminal window
curl -k -u 'admin:hunter2' -X DELETE \
https://hal0.local/api/auth/tokens/a1b2c3d4

Revocation is immediate. The next request with that token gets 401 auth.invalid.

ScopeWhat it grants
adminFull access including token CRUD (/api/auth/tokens).
allChat, embed, audio, slot/model/hardware reads. No token CRUD.
v1-onlyOpenAI-compatible /v1/* only. Most third-party clients use this.
read-onlyGET probes (status, metrics, listings). No mutations.

Even with auth on, these endpoints stay open:

EndpointWhy
/api/health/systemLiveness for monitoring tools.
/api/statusDashboard liveness ping.
/api/metricsPrometheus / dashboard scrape.
/api/featuresFeature-flag inspection.
/api/install/stateFirst-run gating (pre-token).
/api/install/completeFirst-run sentinel write.
/api/config/urlsHost-aware URL hints.
/api/auth/statusAuth-mode discovery (no credentials revealed).
/api/auth/loginPlaceholder (Caddy owns real login).
/v1/modelsOpenAI clients probe this before authenticating.

Everything else under /api/* and /v1/* requires authentication.

hal0.local only resolves if mDNS is set up. The installer drops an Avahi service file when avahi-daemon is present on the host:

Terminal window
# On the hal0 host:
sudo apt install -y avahi-daemon # Debian/Ubuntu
sudo pacman -S avahi # Arch/CachyOS
sudo systemctl enable --now avahi-daemon

Re-run the installer — it’ll detect avahi this time and announce the service. Verify from a client on the same broadcast domain:

Terminal window
avahi-resolve -n hal0.local
# → 10.0.1.230 hal0.local

Without avahi, add a static /etc/hosts entry on each client:

Terminal window
echo "10.0.1.230 hal0.local" | sudo tee -a /etc/hosts

(Substitute your hal0 box’s IP.)

Disable auth and revert to the trusted-LAN posture:

Terminal window
sudo systemctl disable --now hal0-caddy
sudo sed -i 's|^HAL0_AUTH_ENABLED=.*|HAL0_AUTH_ENABLED=0|' /etc/hal0/api.env
sudo HAL0_AUTH_ENABLED=0 \
/opt/hal0/.venv/bin/python -m hal0.openwebui.env_writer
sudo systemctl restart hal0-api hal0-openwebui

The dashboard goes back to http://<host>:8080/, OpenWebUI to http://<host>:3001/, with no credentials required. Caddy’s configuration and TLS state remain on disk so you can re-enable with systemctl enable --now hal0-caddy without re-running the installer.

  • Caddy owns the browser-auth boundary. Swapping basic_auth for OIDC, SAML, or magic-link auth is a Caddyfile change — not a hal0 change. The X-Forwarded-Email contract stays the same.
  • Bearer tokens for programmatic access. No browser handshake, no cookies, OpenAI-SDK-compatible.
  • Off by default. Existing trusted-LAN installs keep working unchanged. Auth is opt-in and reversible.
  • Automatic HTTPS. No certbot, no renewal cron, no nginx config. Caddy handles the entire ACME pipeline including OCSP stapling and renewal.

These are intentionally not in the v0.2 POC:

  • OIDC / OAuth / SAML / magic-link auth (planned for v0.3 via caddy-security or Authelia).
  • Per-user audit log of token use (only last_used_at timestamp today).
  • Token expiry / TTL.
  • Programmatic admin enrolment (you set the admin via installer prompt; rotating it today means re-running the installer).