Auth & Security Architecture

The CRM uses a layered auth model: Cloudflare Access ZTNA at the edge for the admin app, with a CF Access → Supabase JWT exchange behind it via the cf-access-auth Edge Function (so every Supabase request is authenticated as auth.uid() = person.id). The client portal uses Clerk separately.

Admin CRM Auth Flow

User → CF Access login → CF_Authorization cookie set
    → SPA loads (web/)
    → cf-access.ts reads CF_Authorization cookie
    → cf-auth.ts POSTs cookie to cf-access-auth Edge Function
        → EF verifies CF JWT against CF Access JWKS
        → EF looks up person by email (service role)
        → EF ensures auth.users row with id = person.id exists
        → EF generates magiclink → exchanges → returns Supabase access_token
    → SupabaseProvider attaches `Authorization: Bearer <jwt>` to every request
    → RLS policies key off auth.uid() (which equals person.id)

Key Design Decision

For Agents

The admin CRM has NO in-app sign-in page — Cloudflare Access handles login at the edge. But the Supabase client is NOT just using the anon key: every request carries a Supabase JWT minted by the cf-access-auth Edge Function with sub = person.id. RLS enforces row-level access based on auth.uid(). Security is layered: ZTNA at the edge AND row-level inside Postgres. If you write a new RLS policy, key it off auth.uid(), not auth.role().

Components

ComponentLocationPurpose
CF Access cookie readerweb/src/lib/cf-access.tsReads CF_Authorization cookie, parses email/sub
Supabase JWT exchangeweb/src/lib/cf-auth.tsCalls cf-access-auth EF, caches token, schedules refresh, exposes ensureSupabaseJwt()
Supabase clientweb/src/lib/supabase.tsxWraps every fetch with Authorization: Bearer <jwt>, retries once on 401, shows AuthError + Reset-session button on failure
Person lookupweb/src/lib/hooks/useCurrentPerson.tsxMatches CF Access email to person table

cf-access.ts Details

  • Reads CF_Authorization cookie from document.cookie
  • Base64-decodes the JWT payload (no signature verification — CF Access already validated it)
  • Caches the decoded identity in a module-level variable
  • Exports: getCfAccessToken() and getCfAccessIdentity()
  • Fallback: when cookie is absent (local dev), CurrentPersonProvider defaults to the sole person record

cf-access-auth Edge Function

The Edge Function at web/supabase/functions/cf-access-auth/ is the active auth path — every Supabase request from the admin CRM rides on a JWT it produced.

Flow

  1. Receives cf-access-token header (CF Access JWT lifted from cookie by the SPA)
  2. Verifies the CF JWT signature against https://${CF_ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs (JWKS)
  3. Reads email and sub claims from the verified payload
  4. Looks up person by email (service role) — rejects with 403 if no person matches
  5. If no auth.users row exists for person.id, creates one with that explicit ID
  6. Generates a magic link via auth.admin.generateLink({ type: "magiclink" })
  7. Fetches /auth/v1/verify?type=magiclink&token=… with redirect: "manual" and pulls access_token/refresh_token from the redirect fragment
  8. Returns { token, refresh_token, expires_at } (token is a standard Supabase access JWT, ~1h expiry, sub = person.id, role = "authenticated")

For Agents

Failure modes to know about: the function returns 401 if the CF JWT is invalid (Invalid Compact JWS for malformed tokens), 403 if no person row matches the email, 500 if CF_ACCESS_TEAM_DOMAIN env var is missing or the magiclink exchange fails. The SPA’s cf-auth.ts wraps the call with an in-flight singleton + 5min refresh margin and exposes ensureSupabaseJwt() to the rest of the app.

RLS Policies

  • Most admin tables have permissive policies for the authenticated role (since every request now has a Supabase JWT with sub = person.id)
  • notification is locked down: emit_notification RPC is the sole INSERT path; users can only SELECT/UPDATE their own rows (commits 8143f2e, 0499f64)
  • The event audit table is partitioned and INSERT-only via fn_audit_event triggers; reads are restricted
  • ZTNA at the edge is the outer layer; RLS keyed on auth.uid() is the inner layer — both must hold
  • The client never sees the service role key — only the publishable anon key, which is used to bootstrap the JWT exchange

Client Portal Auth (Userspace)

The client-facing portal at userspace/ uses a separate auth system:

  • Provider: Clerk (separate instance from any previous admin Clerk setup)
  • Flow: Standard Clerk OAuth/JWT flow with sign-in page
  • Purpose: External clients access their own project data, invoices, files

Environment Variables

VariablePurpose
VITE_SUPABASE_URLSupabase project URL
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEYSupabase anon key (admin CRM)
VITE_CLERK_PUBLISHABLE_KEYClerk key (userspace only)

Migration History

The auth system was migrated from Clerk to CF Access ZTNA in late March 2026:

  1. b1c09e5 — Default to sole person when CF Access cookie absent (dev mode)
  2. 02077fa — Open RLS policies for all admin tables behind ZTNA
  3. 769ba5c — Simplify Supabase client to plain anon key (remove JWT logic)
  4. bcb446f — Open RLS on all 48 tables for anon access

See CF Access Auth Migration (2026-03-28 to 2026-04-01) for details.

JWT exchange reintroduced (April 2026)

After the simplification above, the JWT flow was put back in place — anon-only proved insufficient once RLS started keying off auth.uid() (notably for the per-user notification, notification_preference, and push_subscription tables in the push-notifications work).

  1. 524cb4a — code quality sweep, auth hardening, RLS enforcement
  2. eccd9a8 — rewrite cf-access-auth to use Supabase admin API + magiclink exchange
  3. ad6e626 — legacy anon key for Edge Function gateway CORS
  4. 8d91ec4 — switch from deno.land/x jose to npm:jose@5.9.6
  5. b4e7400 — automatic background JWT refresh + 401 retry in supabase.tsx

Service-worker incident (2026-05-10)

The custom SW added in ac5f429 registered a NavigationRoute that intercepted page navigations and served cached /index.html, blocking CF Access from running its 302→IdP→fresh-cookie redirect dance. Once cookies expired, all users got "No CF Access token available" and refresh did not recover. Fixed in cd8394f. See incident-2026-05-10-sw-cf-access-lockout for the full postmortem and the rule: never reintroduce a NavigationRoute behind ZTNA without passing 30x responses through to the network.