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

Writing a NEW write policy — the operator gate is role-only, not auth.uid()

The “key it off auth.uid()” guidance above applies to row-ownership (per-person tables like notification / file). For a new write feature on shared data, the operator gate is the authenticated role itself (ZTNA already vetted the operator upstream), so the predicate is to authenticated using (true) with check (...) — canonicalized by 20260426100000_enforce_authenticated_rls.sql. Full convention + the secret-column defense (pmv2_wallets.size_scale): crm-rls-write-auth-convention.

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.