Supabase Setup¶
Supabase serves two roles for the backoffice:
- Auth provider — email OTP and Google OAuth for
client-backoffice - PostgreSQL host — analytics data, config tables, RLS-secured access
Authentication Flow¶
Email OTP Login¶
Key Design Decisions¶
shouldCreateUser: true— supports both pre-provisioned members and new self-serve onboarding usersbefore_user_createdhook — rejects malformed signup events without an email; it is not an authorization boundarybackoffice_users+ RLS — an Auth account alone grants no client data; existing members receive their assigned domains and other users remain in onboarding- Error suppression — frontend always shows the OTP entry regardless of send success/failure, preventing email enumeration
- Code-only template — the Magic Link / OTP template contains
{{ .Token }}and no verification URL, so email security scanners cannot consume the token
Auth Email Delivery¶
Hosted Supabase Auth sends Rose authentication emails through the Cloudflare Email SMTP service configured in Supabase Dashboard → Authentication → SMTP. This replaces Supabase's built-in email service, which is capped at 2 emails/hour and is only intended for project setup/testing.
Supabase Auth remains the source of truth for generating signup, email OTP, invite, email-change, and password-reset tokens. Cloudflare is only the delivery provider: Supabase connects to the Cloudflare SMTP endpoint and sends the rendered Auth email to the user.
Required hosted settings:
- Custom SMTP: enabled, using the Cloudflare Email SMTP credentials.
- Magic Link email template: copy
supabase/templates/email_otp.htmlinto Supabase Dashboard → Authentication → Email Templates → Magic Link. Hosted projects do not read this template fromsupabase/config.toml. - Auth email rate limit:
30emails/hour in Supabase Dashboard → Authentication → Rate Limits (rate_limit_email_sent = 30via the Management API). - Secrets: SMTP password/token lives only in Supabase project settings or secret storage; never commit it to this repo.
Google OAuth¶
Google sign-in is also available. After OAuth, the link_current_user_to_backoffice RPC links auth.users.id to backoffice_users.user_id.
Access Control¶
User Provisioning Flow¶
- Admin creates entry in
backoffice_users(email, display_name, is_admin) - Admin assigns domains via
backoffice_user_domains - User visits backoffice and logs in (email OTP or Google)
- On first login, auth account is created (hook allows it) and linked to
backoffice_users - RLS policies scope all data queries to the user's assigned domains
RLS Architecture¶
| Table | Policy | Scope |
|---|---|---|
conversations |
has_domain_access(site_domain) |
User's assigned domains |
visitors |
has_domain_access(site_domain) |
User's assigned domains |
accounts |
has_domain_access(site_domain) |
User's assigned domains |
messages |
Via conversation's site_domain |
User's assigned domains |
backoffice_users |
Own record or admin | Self + admin |
backoffice_user_domains |
Own assignments or admin | Self + admin |
Admins (is_admin = true) bypass domain scoping and see all data.
before_user_created Hook¶
What It Does¶
A PostgreSQL function (public.before_user_created_hook) that runs inside Supabase's auth flow before a new account is created. It requires a non-empty email, then allows account creation. Authorization remains in backoffice_users and RLS; unprovisioned users can only reach self-serve onboarding.
Migration¶
Created by 20260226163802_add_before_user_created_hook.sql and relaxed for self-serve onboarding by 20260702102821_onboarding_self_serve.sql:
- Creates the hook function
- Restricts EXECUTE to
supabase_auth_adminonly
Production Setup¶
Required: Enable hook in Supabase dashboard
The migration deploys the function, but the hook must also be enabled in the dashboard. This keeps hosted Auth behavior aligned with the versioned self-serve signup validation. Client-data authorization does not depend on the hook.
Steps:
- Go to Supabase Dashboard → Authentication → Hooks
- Enable Before User Created
- Set type: Postgres function
- Set schema:
public - Set function:
before_user_created_hook - Save
Local Development¶
For the full seeding workflow (synthetic data, copy-from-prod, materialized view refresh) see Supabase Seeding. For per-branch isolated DBs see Supabase Preview Branches.
Seed Users¶
supabase/seed.sql creates two users after supabase db reset or just seed-branch:
| Password | Role | |
|---|---|---|
admin@admin.com |
admin |
Admin (all domains) |
user@user.com |
user |
Regular user (sees domains granted via backoffice_user_domains) |
Email Testing¶
Local emails are captured by Inbucket (not actually sent): http://localhost:54324
Local Docker Supabase does not use the hosted Cloudflare SMTP service unless
[auth.email.smtp] is explicitly configured in supabase/config.toml. Keep
local development on Inbucket for normal testing; use the hosted project or a
preview branch when you need to verify Cloudflare SMTP delivery end-to-end.
Hook Configuration¶
The hook is enabled locally via supabase/config.toml:
[auth.hook.before_user_created]
enabled = true
uri = "pg-functions://postgres/public/before_user_created_hook"
This matches production behavior. To test the hook locally:
- Try with an email not in
backoffice_users→ OTP appears in Inbucket and verification routes to onboarding - Try with
admin@admin.com→ OTP appears in Inbucket and verification opens the provisioned backoffice
Security invariants¶
Public self-serve signup means the authenticated role is any stranger with a JWT, not a vetted user. Hold these whenever you touch RLS / auth:
- Use
is_super_admin()for global staff / super-admin bypass. It is the clear alias for legacyis_backoffice_admin(), which checksbackoffice_users.is_admin; neither name means customer account admin. Account-admin access flows throughhas_domain_access(...),has_domain_data_access(...), andmy_admin_client_ids(). - No
USING (true)forauthenticatedon a tenant table. Scope every read policy tohas_domain_access(...)/has_domain_data_access(...)/is_super_admin()(global staff / super admin only). Enforced byjust lint-migrationsand theSupabase Migration LintCI step (.github/workflows/pr-checks-supabase.yml); a genuinely-public table needs-- lint-allow: authenticated-using-true: <reason>on theCREATE POLICY. - Every
SECURITY DEFINERfunction in a PostgREST-exposed schema must self-authorize —is_super_admin()(global staff / super admin) /has_domain_access(p_domain)at the top — or be in the explicit anon/widget allowlist. The IX-3610 grant loop grantsauthenticatedEXECUTE to all of them, so a missing guard is a cross-tenant hole (that is exactly howget_account_for_outreach_agentleaked account data + chat transcripts). Thesupabase-securityskill (run per-migration, above) catches these. - Revoke
anonfrom every new function, by name. Supabase's default privileges grant EXECUTE directly toanonon functions created inpublic, so a newSECURITY DEFINERfunction is callable with the public anon key the moment it exists — andREVOKE ... FROM PUBLICdoes not undo a direct grant. WriteREVOKE ALL ON FUNCTION <name>(<args>) FROM PUBLIC, anon, authenticated;naming the function, then grant back only what genuinely needs it. ADO-loop revoke that filters on a predicate does not count unless it names the function: one looked correct in20260828061302and still shippedsync_site_for_domainanon-executable to production (IX-4522, after IX-3610 and IX-4443). Enforced byjust lint-migrations, the CI job, and the.claude/hooks/check.shedit hook; a genuinely anon-facing RPC needs-- lint-allow: definer-anon-execute: <reason>.20260828154547completes the IX-3610 guard by narrowing the default privilege itself (IX-4532). Note the shape:ALTER DEFAULT PRIVILEGES ... IN SCHEMA x REVOKE EXECUTE ON FUNCTIONS FROM PUBLICis a no-op — a schema-scoped entry merges with Postgres's built-in default, whose function default is EXECUTE to PUBLIC, and every role inherits PUBLIC. Only the schema-lessALTER DEFAULT PRIVILEGES FOR ROLE postgres REVOKE EXECUTE ON FUNCTIONS FROM PUBLICdrops it; the scoped form is still needed for the per-roleanon/authenticatedgrants Supabase adds. That is why IX-3610's guard read as correct for a year and changed nothing. The lint remains the only protection for functions created bysupabase_admin, whose default ACLpostgrescannot alter. Confirmed on a preview branch after20260828154547:pg_default_aclholdspostgres/ global ={postgres=X/postgres}(PUBLIC gone) andpostgres/public={postgres=X,service_role=X}, and a function created bypostgresin any schema comes out with noanon, noauthenticatedand no PUBLIC. Thesupabase_admin/publicrow still reads{postgres=X,anon=X,authenticated=X,service_role=X}— that is the hole the lint covers. Two further lint rules follow from the defaults granting nothing: a migration thatDROPs one of the three widget RPCs (get_widget_config,get_active_staff_experiment,get_active_domain_experiment) must restateGRANT EXECUTE ... TO anonin the same file or the widget breaks for every visitor, and a migration that creates a non-trigger function in an exposed schema must state its EXECUTE grants —GRANT EXECUTE ON FUNCTION public.my_rpc() TO authenticated;(oranon/service_role) — or carry-- lint-allow: no-execute-grant: <reason>when service_role-only is the intent. Infer the role from the caller (backoffice UI →authenticated, widget →anon, job/worker →service_role); when the caller is unclear, ask rather than guessing — a wrong grant is either a 403 or a cross-tenant leak. - Resolve access by exact domain or registered alias. Access decisions resolve the exact domain (or its registered alias via
canonical_domain); never root-normalize a domain to grant access — two different clients can share a registrable root (e.g.welcometothejungle.com). Root-normalization is forwwwcanonicalization only, never a tenant boundary. - Keep email/password signup disabled OR
enable_confirmations = truein the hosted GoTrue config. The onboarding claim gate trusts the JWT email as proof of mailbox ownership; that holds only if the email is verified (magic-link / OAuth). Password signup without confirmation would be instant domain takeover.
Choosing schemas and Data API exposure¶
- Prefer a non-
publicschema for new tables — current direction. The goal is to move a maximum of new tables out ofpublicfor security (anything inpublicis on the PostgREST API surface and safe only by RLS + grants) and for better semantics (a schema per domain:analytics,config,geo,hubspot,knowledge, …). Decide by who reads the table: - Backend/pipeline-internal, no frontend read → put it in a dedicated unexposed schema (e.g.
analytics). Unexposed = unreachable by any API caller = strongest isolation. The service still reaches Postgres only through PostgREST, so it cannot INSERT into an unexposed schema directly — write via aSECURITY DEFINERRPC in an exposed schema (revoke EXECUTE frompublic/anon/authenticated, service-role only). Pattern:analytics.posthog_batch_dlqwritten bypublic.record_posthog_batch_dlq(IX-4005), mirroringbulk_patch_conversation_flags. -
Frontend/app reads it via PostgREST → it must live in an exposed schema. Backoffice-only tables go in the already-exposed
backofficeschema (precedent:backoffice.notices), notpublic.<prefix>_*— no dashboard step either way, smallerpublicsurface. Outsidepublictwo safety nets stop working:rls_auto_enablenever fires, so write an explicitENABLE ROW LEVEL SECURITYper table (a missed one ships an open table), and table grants toservice_roleare dead withoutGRANT USAGE ON SCHEMA <s> TO service_role. Always scope RLS (neverUSING (true)forauthenticatedon tenant data). -
Exposing a new Postgres schema to the Data API is remote-project configuration, not a migration.
config.toml[api] schemasonly configures the local stack; production's exposed-schema list is set in Dashboard → Project Settings → API → Exposed schemas and can drift fromconfig.toml(e.g.vectorcois inconfig.tomlbut not exposed on prod). Preview branches are configured byscripts/supabase_branch.py ensure: add required frontend schemas toREQUIRED_EXPOSED_SCHEMAS, and the script preserves the branch's existing PostgREST list while appending them through the Management API. Asupabase.schema('x')/client.schema('x')call against an unexposed schema failsPGRST106: Invalid schema. This is exactly why an internal table belongs in an unexposed schema written via RPC (above) rather than an exposed one — smaller surface.
Auditing RLS drift and the raw PostHog exception¶
-
The auto-RLS event trigger (
rls_auto_enable, migration20260407152052) only fires onCREATE TABLE— it does not catch a later manualALTER TABLE ... DISABLE ROW LEVEL SECURITY, nor a table created before 2026-04-07. "We have auto-RLS" is not a guarantee everypublictable is covered. Periodically runmcp__supabase__get_advisors(project_id, type: "security")(or thesupabase-securityskill) against the live project, not just on new migrations, to catch drift (IX-4006:posthog_events_rawcarried a dead policy on an RLS-disabled table, invisible to per-migration checks since it predated them). -
public.posthog_events_rawis ACL-only on purpose — never enable RLS on it. PostHog's direct database export writes the table withCOPY FROMas theposthog_exportlogin role, and PostgreSQL refusesCOPY FROMon an RLS-enabled table for a non-owner role. Enabling RLS therefore stops ingestion outright: that is IX-4043, ~12h of paused export on 2026-07-17, caused by IX-4006 acting on the false premise that "the service-role client bypasses RLS regardless" (the writer is not the service-role client). Security on this table is grants:relaclgives onlypostgres+service_role(full),n8n_readonly(SELECT),posthog_export(INSERT);PUBLIC,anonandauthenticatedhold nothing, so PostgREST cannot reach it. Consequences for anyone auditing: - The Supabase advisor finding
rls_disabled_in_publicon this table is KNOWN-ACCEPTED. Do not "fix" it, do not file it again — note it as accepted and move on. - The invariant (RLS off, no inert policies, nothing granted to
PUBLIC/anon/authenticated, no role outside the four above) is asserted by aDO … RAISE EXCEPTIONblock inside20260827095020_ix4445_acl_only_posthog_events_raw.sql, so any environment applying migrations fails loudly if the state has drifted.supabase/tests/ix4445_posthog_events_raw_acl_only.test.sqlre-checks it on demand — nothing in CI runssupabase/tests/*, so ongoing drift detection is the live advisor sweep above, not that file. - If the export is ever migrated off
COPY FROMto plainINSERT, this constraint disappears and RLS can be reconsidered — until then, out of scope (IX-4445).