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¶
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 byPROVISIONER_INTERNAL_TOKEN. Service-to-service only. Caddy also blocks this path from the public internet as defense-in-depth, even thoughsudo-apichecks the token./v1/admin/*— gated by Supabase JWT plus anADMIN_EMAILSallowlist. 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:
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.