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-authEdge Function withsub = person.id. RLS enforces row-level access based onauth.uid(). Security is layered: ZTNA at the edge AND row-level inside Postgres. If you write a new RLS policy, key it offauth.uid(), notauth.role().
Components
| Component | Location | Purpose |
|---|---|---|
| CF Access cookie reader | web/src/lib/cf-access.ts | Reads CF_Authorization cookie, parses email/sub |
| Supabase JWT exchange | web/src/lib/cf-auth.ts | Calls cf-access-auth EF, caches token, schedules refresh, exposes ensureSupabaseJwt() |
| Supabase client | web/src/lib/supabase.tsx | Wraps every fetch with Authorization: Bearer <jwt>, retries once on 401, shows AuthError + Reset-session button on failure |
| Person lookup | web/src/lib/hooks/useCurrentPerson.tsx | Matches CF Access email to person table |
cf-access.ts Details
- Reads
CF_Authorizationcookie fromdocument.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()andgetCfAccessIdentity() - Fallback: when cookie is absent (local dev),
CurrentPersonProviderdefaults 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
- Receives
cf-access-tokenheader (CF Access JWT lifted from cookie by the SPA) - Verifies the CF JWT signature against
https://${CF_ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs(JWKS) - Reads
emailandsubclaims from the verified payload - Looks up
personby email (service role) — rejects with 403 if no person matches - If no
auth.usersrow exists forperson.id, creates one with that explicit ID - Generates a magic link via
auth.admin.generateLink({ type: "magiclink" }) - Fetches
/auth/v1/verify?type=magiclink&token=…withredirect: "manual"and pullsaccess_token/refresh_tokenfrom the redirect fragment - 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 JWSfor malformed tokens), 403 if no person row matches the email, 500 ifCF_ACCESS_TEAM_DOMAINenv var is missing or the magiclink exchange fails. The SPA’scf-auth.tswraps the call with an in-flight singleton + 5min refresh margin and exposesensureSupabaseJwt()to the rest of the app.
RLS Policies
- Most admin tables have permissive policies for the
authenticatedrole (since every request now has a Supabase JWT withsub = person.id) notificationis locked down:emit_notificationRPC is the sole INSERT path; users can onlySELECT/UPDATEtheir own rows (commits8143f2e,0499f64)- The
eventaudit table is partitioned and INSERT-only viafn_audit_eventtriggers; 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
| Variable | Purpose |
|---|---|
VITE_SUPABASE_URL | Supabase project URL |
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY | Supabase anon key (admin CRM) |
VITE_CLERK_PUBLISHABLE_KEY | Clerk key (userspace only) |
Migration History
The auth system was migrated from Clerk to CF Access ZTNA in late March 2026:
b1c09e5— Default to sole person when CF Access cookie absent (dev mode)02077fa— Open RLS policies for all admin tables behind ZTNA769ba5c— Simplify Supabase client to plain anon key (remove JWT logic)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).
524cb4a— code quality sweep, auth hardening, RLS enforcementeccd9a8— rewritecf-access-authto use Supabase admin API + magiclink exchangead6e626— legacy anon key for Edge Function gateway CORS8d91ec4— switch fromdeno.land/xjose tonpm:jose@5.9.6b4e7400— automatic background JWT refresh + 401 retry insupabase.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.
Related
- levandor-crm - Project overview
- agent-context-crm - Agent quick reference
- integrations - CF Access and Cloudflare details
- debugging-log-crm - Auth migration debugging