Skip to content

Auth model

There is no single login that unlocks everything. Every hop has its own bearer. This page is the complete map of who-trusts-whom. If you're debugging a 401, start here.

The hops

Auth model — every hop has its own bearer

The table

Hop Credential Notes
Browser → sudo-api Supabase JWT (Authorization: Bearer <token>) Issued by Supabase Auth. Verified by cloud/_shared/supabase_auth.py.
Pi → sudo-api Device JWT (HS256, 1-year) Minted at /v1/claim/confirm during pairing. Verified by cloud/_shared/jwt_util.py.
voice-bridge → sudo-api PROVISIONER_INTERNAL_TOKEN Shared bearer for /v1/internal/*. Private to the compose network.
sudo-api / voice-bridge → hermes API_SERVER_KEY = HMAC(JWT_SECRET, user_id) Derived per-user in provisioner.api_key_for(). Never stored — recomputed every call. Plus X-Hermes-Session-Key: user_<uuid>.
Pi → livekit-server LiveKit access token From /v1/me/livekit_token, scoped to room_<user_id>.
voice-bridge → livekit-server agent-side LiveKit token From /v1/internal/livekit_agent_token.
Twilio → sudo-api X-Twilio-Signature The WhatsApp webhook is public but signature-verified before anything is forwarded.

The three "zones" of the API

sudo-api's routes split into three trust zones:

  • /v1/me/* — gated by the Supabase JWT. Things a logged-in user does.
  • /v1/internal/* — gated by PROVISIONER_INTERNAL_TOKEN. Service-to-service only. Caddy also blocks this path from the public internet as defense-in-depth, even though sudo-api checks the token.
  • /v1/admin/* — gated by Supabase JWT plus an ADMIN_EMAILS allowlist. The admin pages (/admin/settings, /admin/users) deliver a shell and the JS verifies admin status via these endpoints.

Page routes vs API routes

The HTML pages (/chat, /integrations, /admin/*) are not auth-gated server-side — they just deliver a shell with bootstrap config. The embedded JS then calls the gated /v1/* endpoints, which enforce auth. So a blank/erroring page usually means an auth failure on the underlying API call, not a routing problem.

The API_SERVER_KEY trick, explained

We never store a secret for each family's agent. Instead we derive it deterministically:

API_SERVER_KEY = HMAC-SHA256(JWT_SECRET, user_id)

Both sudo-api and voice-bridge can compute it from the user's id and the shared JWT_SECRET, so there's nothing to keep in sync and nothing to leak from a database. The hermes container is told its key at spawn time; callers recompute it on the fly. See provisioner.api_key_for().

LLM credentials are global and admin-only

There are no per-user LLM keys. The provider/model/key live once in public.global_settings under llm_provider, edited at /admin/settings, and injected into every hermes container at docker run time. This is why a single bad/expired LLM key can take down all surfaces at once — see the Runbooks.