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
PyJWKClientagainst<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.usersmirror. Mirror table with astatuscolumn (active/revoked). Middleware’s existing per-request workspace-role DB lookup (required by the immediate-workspace-revocation rule) extends to also checkstatus. Zero additional DB hits. - D4c — Mirror sync via Supabase Database Webhook on
auth.usersUPDATE/DELETE → FastAPI endpoint updatingcore.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
accountsrow and sets the user asowner. The only OAuth path grantingownerwithout a pre-existing invite. - All subsequent users: callback must match a pending
workspace_invitesrow. No match → reject with an invited-only UX. No danglingauth.usersrows 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;AccountRoleandWorkspaceRoleenums; the 11Scopeconstants (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;AuthErrorKindclosed taxonomy (7 variants —invalid_token,token_expired,user_revoked,workspace_revoked,insufficient_scope,invite_required,backend_unavailable).JwtVerifierProtocol deferred — added when a non-API context needs token verification.- The concrete Supabase verifier and cookie/session helpers live in
spectral_apiinfrastructure. - 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')andcurrent_setting('app.workspace_id')(set by pool checkout per TA-3). - Policies must not reference
auth.uid()orauth.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.authis a new core namespace. Contract tests required per ADR-065. Surface landed at commitd8042c7:__init__.py,context.py,roles.py,scopes.py,errors.py.core.usersmirror is the thirdcoreschema table (aftercore.llm_usage,core.embedding_profile). Migration20260421022000_core_users_mirror.sqllands 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/ssrv1.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.mdxpage picks up the new names in close-pass.
References
- ADR-002 — retired; original Supabase platform decision distilled into the ADR-046 addendum
- ADR-065 —
spectral.coreadmission discipline - ADR-032 —
coreschema; per-context roles - ADR-033 — app-layer primary; RLS backstop; session-var convention (D13)
- ADR-034 — frontend
@supabase/ssrfor 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
stagingEnvironment - 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/(commitd8042c7) — landed contract surfacesupabase/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)