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)org_id UUIDstatus TEXT check ('active' | 'revoked')created_at, revoked_atThe middleware’s per-request domain-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 domain-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, org_id, status, created_at)SELECT u.id, $1 AS org_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 org = the only path that auto-grants owner. All subsequent users must match a pending domain_invites row. No match → reject with the invited-only UX.
Issue an invite
domain_invites is provisioned by the Operations Agent or directly in SQL:
INSERT INTO platform.domain_invites (domain_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, domain_id, email, role, expires_at, invited_byFROM platform.domain_invitesWHERE consumed_at IS NULL AND expires_at > now()ORDER BY domain_id, email;Privileged role assignment
owner and operations are org-level roles persisted in platform.org_members
(organization_role), not in auth.users.app_metadata. The access-token hook
injects app_metadata.organization_role from the member’s active org_members
row at every token mint — so editing app_metadata directly does not persist
(the next mint overwrites it). Grant a role by writing the membership row:
- Insert/update a
platform.org_membersrow (org_id,user_id,organization_role) underplatform_role, plus thecore.usersmirror row. A user with no activeorg_membersrow gets neither theorganization_roleclaim nor decision scopes. operationsis held only by Spectral staff in the Spectral-internal org, never in a customer org (ADR-086 D5).
The first-user-of-new-org owner grant via self-service signup (CreateOrg,
ADR-105 D4) is the single path that grants owner without a pre-existing invite.
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/provision.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 domain UI shows empty state → check
platform.membershipsfor the domain; 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