Authentication runbook
Operational procedures for Supabase Auth, JWKS rotation, the core.users revocation mirror, and the invite-gate flow.
System reference: Codex how-to/authentication.mdx · ADR-039.
Substrate
- Auth provider: Supabase Auth (US-East AWS, managed)
- OAuth providers at alpha: Google, GitHub (login-only scopes)
- Methods: email+password AND OAuth; magic link / phone-SMS deferred
- JWT verification: JWKS-local via PyJWT’s
PyJWKClientagainst<project>.supabase.co/auth/v1/.well-known/jwks.json(FastAPI middleware) and@supabase/supabase-jsgetClaims()(Operations Start). Cloudflare Pages Function reuses the same pattern with KV-backed cache.
JWKS rotation
Supabase rotates signing keys by kid rather than by time. The rotation procedure on the Spectral side:
- No action required for routine rotations. New
kids are picked up automatically byPyJWKClientcache miss + refetch (FastAPI),getClaims()(Operations Start), and the Pages Function (KV miss-bypass). - Verify rotation visibility. After Supabase rotates, query a fresh JWT against each verifier:
- FastAPI:
curl -H "Authorization: Bearer <jwt>" https://api.runspectral.com/health/auth-check - Operations Start: log in fresh; observe successful gate.
- Pages Function: open
codex.runspectral.com/_authcheck; observe successful pass-through.
- FastAPI:
- If a verifier returns “unknown kid”: the Pages Function KV cache may be stale beyond TTL. Force-bypass by appending
?nokv=1to a request, or wait for the 10-minute TTL to expire.
The contract test in the API + Operations Start integration suite asserts parity between the three verifiers on the same JWT inputs.
core.users revocation mirror
core.users is a minimal mirror of auth.users:
id UUID PK (matches auth.users.id)account_id UUIDstatus TEXT check ('active' | 'revoked')created_at, revoked_atThe middleware’s per-request workspace-role lookup also reads status. Zero additional DB hits per request.
Revoke a user
UPDATE core.usersSET status = 'revoked', revoked_at = now()WHERE id = $1;Effect: revocation propagation is bounded by the JWT TTL plus the per-request middleware freshness check against the core.users mirror’s status column (per ADR-039 D4b — same per-request DB lookup as workspace-role; zero additional DB hits). The JWKS cache TTL governs signing-key rotation, not user-revocation propagation.
Sync mirror from auth.users (manual fallback)
If the webhook is not yet wired:
INSERT INTO core.users (id, account_id, status, created_at)SELECT u.id, $1 AS account_id, 'active', u.created_atFROM auth.users uWHERE u.id = $2ON CONFLICT (id) DO NOTHING;Invite-gate flow
First OAuth user of a brand-new account = the only path that auto-grants owner. All subsequent users must match a pending workspace_invites row. No match → reject with the invited-only UX.
Issue an invite
workspace_invites is provisioned by the Operations Agent or directly in SQL:
INSERT INTO platform.workspace_invites (workspace_id, email, role, scopes, invited_by, expires_at)VALUES ($1, $2, 'contributor', $3, $4, now() + interval '7 days');The user accepts via OAuth or email+password; the callback handler matches the invite, creates membership, marks the invite consumed.
List pending invites
SELECT id, workspace_id, email, role, expires_at, invited_byFROM platform.workspace_invitesWHERE consumed_at IS NULL AND expires_at > now()ORDER BY workspace_id, email;Privileged role assignment
owner and operations are not self-service. Grant via:
- Supabase Studio: edit
auth.users.app_metadatato setaccount_role - Direct DB: update
app_metadatathen sync tocore.usersif needed
The first-user-of-new-account owner grant from invite-gate is the single self-service exception.
OAuth provider configuration
Per environment, separate OAuth clients:
- Google Cloud Console — staging + production OAuth clients with per-env redirect URIs
- GitHub Apps — staging + production apps with per-env redirect URIs
Redirect URIs configured in Supabase dashboard. Client credentials provisioned via tools/provision/setup.sh per @scope=shared.
MFA
TOTP is opt-in per user via Supabase. SMS deferred (paid + privacy footprint). Passkey/WebAuthn deferred (Supabase has not shipped as a first-class factor). No enforcement at alpha; enforcement trigger = first design-partner contract requiring it.
Troubleshooting
- User logs in but workspace UI shows empty state → check
platform.membershipsfor the workspace; also checkcore.users.status='active'. - PKCE cookie split causes auth fail at proxy → the session cookie may exceed 4096 bytes (Azure / Google OAuth large claims).
@supabase/supabase-jssplits on the client; reassembly happens in the FastAPI middleware, the Operations Start auth handler, and the Pages Function. The first-integration test exercises this path; if it regresses, look for missingCookie:headers in the logs. - Users report logging in repeatedly → check Supabase Auth status page; check the JWKS endpoint; check Cloudflare Pages Function KV TTL.
See also
- ADR-039 — Supabase Auth confirmation + hardening
- Codex authentication
- Codex access control — RLS session-var convention
docs/runbooks/secrets-management.md— OAuth client secret rotation