Skip to content
GitHub
Decisions

ADR-039: Supabase Auth confirmation and hardening — JWKS-local verification, mirror-based revocation, invite-gate

Status: Accepted (2026-04-21)

Context

ADR-002 locked Supabase for DB + Auth + pgvector in March 2026. TA-18 revisits the auth-provider decision: is Supabase Auth the right baseline for alpha given everything we have learned in the tech-arch review and everything that has changed in the auth landscape since?

Already locked: ADR-002 Supabase DB + Auth + RLS + pgvector; ADR-033 D1/D2 app-layer tenancy primary / RLS backstop; ADR-034 frontend @supabase/ssr for Auth only; ADR-037 D1/D4 OAuth client secrets as platform-shared @scope=shared secrets; ADR-036 D5 structlog canonical fields.

Initial position: light confirmation. The landscape survey and adversarial pair converged on the same verdict from opposite entry points — the switch-now case against Supabase Auth has real substance (HS256 blast-radius in legacy projects, OAuth-authenticates-before-app-authorizes ordering, auth.uid() RLS lock-in) but every concern is fixable on top of Supabase, not by switching away. Cost dominance at alpha scale is overwhelming ($25/month Supabase Pro covers ≤100K MAU; Clerk is ~20× more expensive at 50K).

The naming-coherence pass in TA-3 D11 (SPEC-306) renamed SCOPE_*_PLATFORM to SCOPE_*_OPERATIONS and PLATFORM_SCOPES to OPERATIONS_SCOPES so the scope identifiers match the role that holds them (AccountRole.OPERATIONS). This ADR uses the post-rename names.

Decision

D1 — Supabase Auth for alpha; no switch

Confirmation of ADR-002. Revisit triggers: first design-partner contract requiring enterprise SAML with IdP breadth (Okta / Azure AD) → re-evaluate WorkOS AuthKit; passkey MFA becomes blocking; sustained >100K MAU; future Auth advisory affecting Google/GitHub flows at ≥GHSA-v36f-qvww-8w8m severity.

D2 — OAuth providers at alpha: Google + GitHub via Supabase

Login-only scopes (openid email profile for Google; read:user user:email for GitHub). No Google sensitive-scope verification review required at login scope. Separate OAuth clients per environment; per-env redirect URIs configured in Supabase dashboard.

D3 — Auth methods at alpha: email+password AND OAuth

Magic link and phone/SMS deferred. Email+password retained as an OAuth fallback (provider downtime, user preference).

D4 — Server-side JWT verification: hybrid JWKS-local + mirror-based revocation

  • D4a — JWKS-local validation. Signature + expiry via PyJWT’s PyJWKClient against <project>.supabase.co/auth/v1/.well-known/jwks.json. No per-request round-trip. JWKS cached ≥20 min per Supabase edge policy.
  • D4b — User-level revocation via core.users mirror. Mirror table with a status column (active / revoked). Middleware’s existing per-request workspace-role DB lookup (required by the immediate-workspace-revocation rule) extends to also check status. Zero additional DB hits.
  • D4c — Mirror sync via Supabase Database Webhook on auth.users UPDATE/DELETE → FastAPI endpoint updating core.users.status. Webhook optional at alpha; until it ships, user-level revocation lags JWKS cache TTL (~20 min). The mirror shape lands now to reserve the upgrade path.

D5 — Client-side JWT claim extraction: getClaims() via @supabase/ssr

Not getSession() (unsafe server-side) and not getUser() (unnecessary round-trip once JWKS-local lands). The JWT is sent to FastAPI via Authorization: Bearer; an X-Workspace-Id header carries workspace context (workspace is not in the JWT per the ADR-033 D5/D6 framing).

D6 — Frontend auth library: @supabase/ssr

Binding from ADR-034. Budget ~1 week in Q3 2026 for the v1.0.0 API migration when it lands.

D7 — Invite-gate enforcement (structural fix for the SEF auto-admin failure mode)

  • First OAuth user of a brand-new account (self-service signup): callback creates the accounts row and sets the user as owner. The only OAuth path granting owner without a pre-existing invite.
  • All subsequent users: callback must match a pending workspace_invites row. No match → reject with an invited-only UX. No dangling auth.users rows with no membership.
  • No role = X or "admin"-style defaults anywhere. Code-review-enforced at alpha; post-alpha ast-grep lint flags the pattern mechanically.

D8 — MFA posture: TOTP opt-in; no enforcement

Supabase-native TOTP is free. SMS deferred (paid; privacy footprint). Passkey/WebAuthn MFA deferred (Supabase has not shipped as a first-class factor). Enforcement trigger: first design-partner contract requiring it.

D9 — SAML/SSO: deferred to post-alpha (SPEC-302)

Trigger: first design-partner contract requiring an enterprise IdP. Decision gate at that time: single customer → Supabase Pro SAML (already in $25/month tier); IdP breadth + SCIM → WorkOS AuthKit alongside Supabase DB (migration is ~2-week re-work per D12).

D10 — Privileged role assignment: no self-service

owner and operations granted via Supabase Studio or direct DB only. The first-user-of-new-account owner grant (D7) is the single exception.

D11 — API keys

Format sk_live_{random}; SHA-256 hashed in api_keys; workspace-bound; scope-restricted to read:agents + write:traces. spectral_api-local, not shared between contexts.

D12 — Auth abstraction discipline

Supabase-Auth-specific types never leak past infrastructure.

  • spectral.core.auth (shared across contexts): AuthContext; AccountRole and WorkspaceRole enums; the 11 Scope constants (4 workspace + 1 account + 2 agent/trace + 4 operations) and grouping frozensets (OBSERVER_SCOPES, CONTRIBUTOR_SCOPES, ADMIN_SCOPES, API_KEY_SCOPES, OPERATIONS_SCOPES); AuthError / AuthResult[T] boundary types; AuthErrorKind closed taxonomy (7 variants — invalid_token, token_expired, user_revoked, workspace_revoked, insufficient_scope, invite_required, backend_unavailable).
  • JwtVerifier Protocol deferred — added when a non-API context needs token verification.
  • The concrete Supabase verifier and cookie/session helpers live in spectral_api infrastructure.
  • Rationale: the ~2-week re-work migration cost estimate is contingent on this. Breaking it would make any future swap a re-architecture.

D13 — RLS backstop references session vars, not auth.uid()

Clarifies the ADR-033 D2 framing.

  • Policies read from current_setting('app.account_id') and current_setting('app.workspace_id') (set by pool checkout per TA-3).
  • Policies must not reference auth.uid() or auth.jwt() — those hardcode Supabase Auth into DB policy.
  • Rationale: RLS is the backstop that matters when app-layer leaks; provider-agnostic policies preserve the migration-is-re-work promise.
  • Enforcement: code review at alpha; post-alpha extend the migration-naming lint to flag auth.uid() / auth.jwt() in SQL.

Alternatives considered

Clerk for alpha. Crosses Supabase cost at ~10K MAU; ~20× more at 50K. No alpha-scale benefit.

WorkOS AuthKit for alpha. Free to 1M MAU but B2B-first; inventing the B2C signup story ourselves. Better positioned as the named migration target when SAML becomes urgent.

Better-Auth self-hosted. Library, not service. Active and mature, but trades vendor cost for ongoing OAuth/MFA/email maintenance. Not viable for solo builder at pre-alpha.

Auth0 / Okta CIC. Pricing-hostile ($150/month at 500 MAU B2B Essentials). Skip.

OAuth-only (drop email+password). Simpler UX but fails users who do not want to SSO; fragile under provider downtime.

Enforce MFA at alpha. Over-engineered for NDA alpha. TOTP opt-in answers the “do you support MFA?” question truthfully.

Keep per-request auth.get_user() round-trip. Immediately revocable but imposes network latency on every request. D4 hybrid gets the same semantics with zero per-request cost once the webhook ships.

Let RLS use auth.uid() for alpha convenience. Every such policy is a future migration line item. Cheaper to establish discipline now.

Put AuthContext in spectral_api, not core. Requires workers and test-agents consuming attribution to import spectral_api, violating layering.

Consequences

  • Alpha auth cost: $25/month (Supabase Pro). Flat through 100K MAU.
  • Migration cost to WorkOS / Clerk / custom if triggered: ~2 weeks re-work. Not a re-architecture.
  • spectral.core.auth is a new core namespace. Contract tests required per ADR-065. Surface landed at commit d8042c7: __init__.py, context.py, roles.py, scopes.py, errors.py.
  • core.users mirror is the third core schema table (after core.llm_usage, core.embedding_profile). Migration 20260421022000_core_users_mirror.sql lands the table and the session-var-bound RLS pattern (D13) — the first migration to demonstrate the convention.
  • User-level revocation latency: ~20 min at alpha; near-immediate once the webhook ships.
  • Passkey MFA gap is real but not alpha-blocking. Track Supabase’s roadmap.
  • Each RLS policy carries a small review-discipline cost (Supabase examples default to auth.uid()).
  • Sovereignty: Supabase Auth in AWS us-east. Fine for US-only NDA alpha. Data-residency revisit deferred to SPEC-302.
  • @supabase/ssr v1.0.0 migration pre-scheduled as a ~1-week task in Q3 2026.
  • Scope rename amendment (TA-3 D11) renamed the operations-scope identifiers; this ADR uses the post-rename names. The Codex access-control.mdx page picks up the new names in close-pass.

References

  • ADR-002 — retired; original Supabase platform decision distilled into the ADR-046 addendum
  • ADR-065spectral.core admission discipline
  • ADR-032core schema; per-context roles
  • ADR-033 — app-layer primary; RLS backstop; session-var convention (D13)
  • ADR-034 — frontend @supabase/ssr for Auth only
  • ADR-037 — OAuth client secrets as @scope=shared
  • ADR-048 — key-exchange middleware (D7); satisfies the auth-substrate dynamic-keys requirement via env-var sourcing
  • ADR-052 — TA-22 reuses JWKS-local pattern at the edge (Cloudflare Pages Function)
  • ADR-062 — OAuth provider test-app credentials in staging Environment
  • TA-18 disposition — SPEC-321 comment e5fc87a9
  • TA-18 verification — SPEC-321 comment f78f5a21
  • TA-18 amendment (scope rename) — SPEC-321 comment 363c329d
  • src/spectral/core/auth/ (commit d8042c7) — landed contract surface
  • supabase/migrations/20260421022000_core_users_mirror.sql
  • Codex developer-guide/authentication.mdx — close-pass updates (JWKS-local pattern; getClaims(); invite-gate)
  • Codex system-design/foundations/access-control.mdx — close-pass updates (D13 RLS session-var convention; OPERATIONS scope taxonomy)