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.
The one-liner
Section titled “The one-liner”sudo bash installer/install.sh --auth=basicThe installer prompts for an admin username and password. After it finishes:
- Dashboard —
https://hal0.local/(basic_auth prompt) - Chat —
https://hal0.local/chat/(single-sign-on, no second login) - API —
https://hal0.local/v1/...(bearer token inAuthorizationheader)
Non-interactive (CI / config management):
HAL0_ADMIN_USER=alex HAL0_ADMIN_PASSWORD='hunter2' \ HAL0_HOSTNAME=hal0.local \ sudo bash installer/install.sh --auth=basicWhat --auth=basic actually does
Section titled “What --auth=basic actually does”-
Installs Caddy via the system package manager —
apt install caddyon Debian/Ubuntu,pacman -S caddyon Arch/CachyOS. On distros without a packaged Caddy, the script surfaces a clear error and points at upstream install docs. -
Renders
/etc/hal0/Caddyfilefrom the bundled template, baking in the admin username and a bcrypt password hash (generated bycaddy hash-password). The plaintext password is never persisted. -
Drops
hal0-caddy.serviceinto/etc/systemd/system/and starts it. Caddy listens on:80(HTTP→HTTPS redirect) and:443(the real surface). -
Flips
HAL0_AUTH_ENABLED=1in/etc/hal0/api.envand re-renders/etc/hal0/openwebui.envso OpenWebUI auto-provisions a user from the Caddy-forwarded identity (no second login). -
Restarts
hal0-apiandhal0-openwebuiso the new env takes effect. -
Drops
/etc/avahi/services/hal0.serviceif avahi-daemon is on the host sohal0.localresolves on the LAN. Without avahi, add a static/etc/hostsentry on each client:<hal0-ip> hal0.local.
How auth works
Section titled “How auth works”Two surfaces share one identity model, gated by HAL0_AUTH_ENABLED.
Browser path
Section titled “Browser path”You hit https://hal0.local/:
-
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. -
Caddy proxies to
127.0.0.1:8080and addsX-Forwarded-Email: <username>to the request. -
hal0-api sees the forwarded header, trusts it (Caddy strips any inbound copy first to prevent spoofing), and treats you as
scope=admin. -
For
/chat/, Caddy proxies to OpenWebUI:3001with the same forwarded header. OpenWebUI’sWEBUI_AUTH_TRUSTED_EMAIL_HEADERauto-provisions a user — no second login.
Programmatic path
Section titled “Programmatic path”Your OpenAI client hits https://hal0.local/v1/chat/completions:
-
The
/v1/*Caddy block has no basic_auth. Request passes through to hal0-api. -
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. -
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.
Precedence
Section titled “Precedence”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.
HTTPS — automatic, every time
Section titled “HTTPS — automatic, every time”Caddy handles TLS termination automatically. The behaviour depends on
the hostname you gave the installer (HAL0_HOSTNAME, default hal0.local).
.local hostnames (LAN deployments)
Section titled “.local hostnames (LAN deployments)”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.crtTrust it on each client:
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.crtsudo update-ca-certificatesCopy the root.crt file to your Mac, double-click it to open in
Keychain Access. Drop it into the System keychain, then
double-click the entry, expand Trust, and set When using this
certificate to Always Trust.
Copy the root.crt file to the Windows host, double-click → Install
Certificate → Local Machine → Place all certificates in the following
store → Browse → Trusted Root Certification Authorities → OK.
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.
Real (DNS-resolvable) hostnames
Section titled “Real (DNS-resolvable) hostnames”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:
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=basicThen edit the Caddyfile to remove tls internal:
sudo sed -i '/tls internal/d' /etc/hal0/Caddyfilesudo systemctl reload hal0-caddySwitching from internal CA to ACME later — same edit, no reinstall:
sudo sed -i 's/^\(\s*\){\$HAL0_HOSTNAME:hal0.local}/\1hal0.example.com/' /etc/hal0/Caddyfilesudo sed -i '/tls internal/d' /etc/hal0/Caddyfilesudo systemctl reload hal0-caddyThat’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
:80must be reachable from the internet (Let’s Encrypt uses the http-01 challenge by default). HAL0_TLS_EMAILmust 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.
What you do not have to do
Section titled “What you do not have to do”- No certbot install, no cron jobs for renewal.
- No nginx
ssl_certificatepaths 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.
Token management
Section titled “Token management”Mint a token
Section titled “Mint a token”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):
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."}Use a token
Section titled “Use a token”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"}]}'Revoke a token
Section titled “Revoke a token”curl -k -u 'admin:hunter2' -X DELETE \ https://hal0.local/api/auth/tokens/a1b2c3d4Revocation is immediate. The next request with that token gets
401 auth.invalid.
Token scopes
Section titled “Token scopes”| Scope | What it grants |
|---|---|
admin | Full access including token CRUD (/api/auth/tokens). |
all | Chat, embed, audio, slot/model/hardware reads. No token CRUD. |
v1-only | OpenAI-compatible /v1/* only. Most third-party clients use this. |
read-only | GET probes (status, metrics, listings). No mutations. |
Public-route allowlist
Section titled “Public-route allowlist”Even with auth on, these endpoints stay open:
| Endpoint | Why |
|---|---|
/api/health/system | Liveness for monitoring tools. |
/api/status | Dashboard liveness ping. |
/api/metrics | Prometheus / dashboard scrape. |
/api/features | Feature-flag inspection. |
/api/install/state | First-run gating (pre-token). |
/api/install/complete | First-run sentinel write. |
/api/config/urls | Host-aware URL hints. |
/api/auth/status | Auth-mode discovery (no credentials revealed). |
/api/auth/login | Placeholder (Caddy owns real login). |
/v1/models | OpenAI clients probe this before authenticating. |
Everything else under /api/* and /v1/* requires authentication.
mDNS — hal0.local resolution
Section titled “mDNS — hal0.local resolution”hal0.local only resolves if mDNS is set up. The installer drops an
Avahi service file when avahi-daemon is present on the host:
# On the hal0 host:sudo apt install -y avahi-daemon # Debian/Ubuntusudo pacman -S avahi # Arch/CachyOSsudo systemctl enable --now avahi-daemonRe-run the installer — it’ll detect avahi this time and announce the service. Verify from a client on the same broadcast domain:
avahi-resolve -n hal0.local# → 10.0.1.230 hal0.localWithout avahi, add a static /etc/hosts entry on each client:
echo "10.0.1.230 hal0.local" | sudo tee -a /etc/hosts(Substitute your hal0 box’s IP.)
Rolling back
Section titled “Rolling back”Disable auth and revert to the trusted-LAN posture:
sudo systemctl disable --now hal0-caddysudo sed -i 's|^HAL0_AUTH_ENABLED=.*|HAL0_AUTH_ENABLED=0|' /etc/hal0/api.envsudo HAL0_AUTH_ENABLED=0 \ /opt/hal0/.venv/bin/python -m hal0.openwebui.env_writersudo systemctl restart hal0-api hal0-openwebuiThe 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.
Why this design
Section titled “Why this design”- 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-Emailcontract 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.
What’s deferred
Section titled “What’s deferred”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_attimestamp today). - Token expiry / TTL.
- Programmatic admin enrolment (you set the admin via installer prompt; rotating it today means re-running the installer).