Skip to content
GitHub
Operator

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 PyJWKClient against <project>.supabase.co/auth/v1/.well-known/jwks.json (FastAPI middleware) and @supabase/supabase-js getClaims() (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:

  1. No action required for routine rotations. New kids are picked up automatically by PyJWKClient cache miss + refetch (FastAPI), getClaims() (Operations Start), and the Pages Function (KV miss-bypass).
  2. 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.
  3. If a verifier returns “unknown kid”: the Pages Function KV cache may be stale beyond TTL. Force-bypass by appending ?nokv=1 to 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 UUID
status TEXT check ('active' | 'revoked')
created_at, revoked_at

The middleware’s per-request workspace-role lookup also reads status. Zero additional DB hits per request.

Revoke a user

UPDATE core.users
SET 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_at
FROM auth.users u
WHERE u.id = $2
ON 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_by
FROM platform.workspace_invites
WHERE 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_metadata to set account_role
  • Direct DB: update app_metadata then sync to core.users if 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.memberships for the workspace; also check core.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-js splits 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 missing Cookie: 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