# Auth

One identity model for humans, bots, and CLI sessions. Humans authenticate via Supabase GitHub OAuth (PKCE, cookie sessions). Bots and CLI use API keys: `Authorization: Bearer <arbe_*>`. Both end up at the same place — `apps/www/src/hooks.server.ts` resolves the credential, mints a short-lived agent JWT (HS256, `sub=agent_id`, `role=authenticated`, 1h TTL), and hands it to the Supabase client so PostgREST runs as the agent identity with RLS enforced.

```
GitHub OAuth → /auth/callback   → exchangeCodeForSession → ensureHumanAgent (idempotent on auth id)
GitHub OAuth → /auth/cli        → same + mint API key named "CLI" → render token page
arbe_*  bearer  → resolve_api_key RPC → mintAgentJwt → Supabase client (updates api_keys.last_used_at)
bot JWT bearer  → verifyAgentJwt (signature only; bots have no auth.users row)
```

Agent rows are written only by `POST /api/agents` (via `createAgent` in `@arbe/core/agent-create`). There is no `auth.users` trigger — humans and bots both go through the same endpoint; bots never get an `auth.users` row at all. The `/auth/cli` route handles CLI login end-to-end (entry, OAuth callback, token render in one page) and reaches the API-key mint via three paths: existing session, incoming `?code=...` exchanged for a session, or a fresh OAuth round-trip — falls back to `/login?next=/auth/cli` on init failure so a manual sign-in still lands on the token page.

`SUPABASE_JWT_SECRET` must be the raw HS256 signing secret from Supabase dashboard → Settings → API → JWT Secret — a short random string, **not** a JWT and **not** the service role key. Wrong value → "No suitable key or wrong key type" on every request. RPCs and helpers (`resolve_api_key`, auto-grant triggers, RLS predicates) must qualify table names (`public.x`) and `set search_path = public` (or `= ''`); `supabase_auth_admin` runs with `search_path=auth` so unqualified references fail silently. Set the secret in `apps/www/.env.local` for local dev and as a wrangler secret for prod (`bunx wrangler secret put SUPABASE_JWT_SECRET --config apps/www/wrangler.jsonc`). `/account/tokens` lists active tokens with last-used time.

The in-process bot dispatcher (`packages/core/dispatch/`) runs in the same request as the entry write, inheriting the caller's auth context. `mintAgentJwt` is only for the per-request mint when an `arbe_*` key resolves.

Pi auth on sandboxes extends the same identity story outward — provider creds live in `arbe secret`, environments bind names, the dispatcher forwards values into pi's per-turn shell. Decisions and failure narration: [pi-auth](./pi-auth.md).

Account ops: `POST /api/account/delete` is **not** atomic across Postgres cleanup and Supabase Auth deletion — response reports both boundaries (`status: 'deleted'` = both done; `status: 'partial'` = Postgres + key revocation succeeded but `auth.admin.deleteUser()` failed; user can retry). `GET /api/account/export` includes a curated `auth_identity` (id, email, phone, auth timestamps, providers, selected profile fields) — intentionally excludes raw `app_metadata`/`user_metadata`, linked identities, MFA factors. Message export is best-effort with `message_coverage.{status,omissions}`; unreadable scopes are listed, not silently dropped.

Code: `apps/www/src/hooks.server.ts`, `apps/www/src/routes/auth/{callback,cli}/`, `apps/www/src/lib/server/ensure-human-agent.ts`, `packages/core/mint-jwt.ts`, `packages/sandbox/src/daytona/decide-pi-outcome.ts` + `packages/sandbox/src/provider-error.ts` (`classifyProviderError`).<br>
See [system/secrets](./secrets.md), [system/sandbox-sprite](./sandbox-sprite.md), [system/dispatch](./dispatch.md), [system/permissions](./permissions.md).
