Database View & Migration RLS Safety
Required reading before writing or reviewing ANY migration that creates or replaces a view, creates a table, or touches RLS. These rules exist because of a real production incident (CFO-7727, 2026-08-28): a routineCREATE OR REPLACE VIEW on transaction_details silently dropped the view’s
security_invoker flag, and for ~12 hours every chat SQL query and saved
dashboard widget against that view returned every user’s transactions —
a cross-tenant data leak caused by two Postgres footguns and one process
bypass, all preventable.
The three footguns that caused CFO-7727
pg_get_viewdef()never emits view options. Copying a view definition from production (or frompg_views) gives you the SELECT — and silently losesWITH (security_invoker = on). A definition “pulled verbatim from prod” is NOT verbatim.CREATE OR REPLACE VIEWresets unspecified options. It preserves ownership and grants, but any reloption you don’t restate (includingsecurity_invoker) is removed. There is no warning.- A view without
security_invokerruns as its owner. Our views are owned bypostgres, which owns the base tables — and table owners bypass RLS. The result is not an error and not zero rows: it is all rows, for every caller, even when the caller carefully setsSET ROLE authenticated+ JWT claims (as the sql.cfosilvia.com query service does).
Rules (enforced in review)
R1 — Every view in public MUST declare security_invoker
Any CREATE VIEW or CREATE OR REPLACE VIEW in a migration must include:
WITH clause back — it was stripped by footgun #1.
R2 — The clause is the requirement; CI and the daily audit enforce it
R1 is enforced twice, neither of which lives inside the migration:- Statically, on every PR:
lib/migrations/__tests__/view-security-invoker.test.tsparses everyCREATE [OR REPLACE] VIEWinsupabase/migrations/**and fails CI when aclerk_id-scoped view does not declare the option in itsWITHclause. This catches the footgun at the point it is introduced — a definition copied from prod, pasted into a new migration. A same-fileALTER VIEW ... SET (security_invoker = on)does not satisfy it: a statement that is only safe once a later statement runs does not survive a partial hand-apply, which is how CFO-7727 happened. Repair migrations that restore the flag on a view they do not redefine (such as20260828_02) contain noCREATEfor that view and are simply not examined. - At runtime, daily:
public.audit_rls_drift()(migration20260828_03, run byapp/api/cron/audit-rls-drift/route.tsat 06:00 UTC) lists everypublicview whosereloptionslacksecurity_invoker, plus four other RLS checks, and alerts Slack on any row. This catches anything that reached the database another way — a manual apply, a hotfix, a migration applied out of order. The SECURITY DEFINER check skips event trigger functions (20261001_01): Postgres only runs them from an event trigger, so no client can call one, and extensions such as pgaudit install them intopublicowned bysupabase_admin, where the suggestedREVOKEis a no-op. The skip is by return type, never by extension, so an extension’s ordinary SECURITY DEFINER functions still alert. EXECUTE on the audit itself isservice_roleonly (revoked fromanonandauthenticatedby name, since Supabase grants them directly).
DO block that
re-checks pg_class.reloptions after the CREATE OR REPLACE. Earlier
revisions of this document required one; it was redundant with the two
checks above and made every view migration longer without adding a case
either check misses. Reviewers (human and CodeRabbit) verify the WITH
clause on the statement itself and stop there. A one-off manual check for a
freshly applied migration is the R6 query below.
R3 — New tables get RLS before they get data
EveryCREATE TABLE in public must be immediately followed by:
authenticated and anon — the two the
client can ever hold. service_role bypasses RLS by role attribute and is
unaffected by anything in this section.
Then pick exactly one of two shapes:
- User-owned tables — write explicit policies scoping on
clerk_id = auth.jwt()->>'sub'. - Server-write-only tables (populated by
service_rolefrom API routes or crons, never read by the client) — enabling RLS and writing no policies at all is correct and sufficient. Postgres denies every row toauthenticated/anonwhen no policy applies, so a deny-all policy would be redundant. Mark these with the existing-- Server-write onlycomment convention so the absence of policies reads as deliberate rather than forgotten.
R4 — Functions: SECURITY DEFINER is opt-in and audited
Default to SECURITY INVOKER. If a function must be SECURITY DEFINER, it
must SET search_path = '' (or an explicit schema list) and perform its own
authorization check on auth.jwt()->>'sub'. See
docs/security/SECURITY-INCIDENT-RPC-FUNCTIONS.md for the incident behind
this rule. Materialized views cannot be security_invoker — never grant
authenticated/anon SELECT on a matview over user data; wrap access in a
scoped function or table instead.
R5 — Migrations go through the blessed flow, never applied by hand
The CFO-7727 view replacement was applied directly to prod with psql/SQL editor at 00:08 UTC and never recorded insupabase_migrations.schema_migrations — so no CI policy check ever saw it.
Migrations must land as supabase/migrations/*.sql files on a PR and be
applied by the pipeline. If an emergency hotfix must be applied manually,
the matching migration file must be committed the same day and the manual
apply called out in the PR description.
R6 — After any view change, verify before merging
Run against the target environment (staging first):foreign_rows = 0.
Fail = the two identities return the same number, either equals the fleet
total, or foreign_rows > 0. Counts alone are not sufficient: a view can
return another tenant’s rows while the totals still look right, so the
ownership check is what actually proves scoping. Pick two users with clearly
different counts, so an accidental match cannot hide a bypass.
A suspiciously large count (hundreds of thousands of rows, nine-figure
dollar aggregates) from a per-user view is the leak signature — treat it as
a failed check, not as “the user has a lot of data”.
For reviewers (human and CodeRabbit)
Flag a PR when any of these hold:- A
CREATE [OR REPLACE] VIEWinsupabase/migrations/**lacksWITH (security_invoker = on). - A
CREATE TABLEinpublicis not followed byENABLE ROW LEVEL SECURITYin the same migration. Policies are then required for user-owned tables (clerk_id = auth.jwt()->>'sub'), while a server-write-only table correctly has none (R3); RLS left disabled is always a finding. - A function is declared
SECURITY DEFINERwithoutSET search_pathand an auth check. - A PR description mentions SQL already applied to prod and any of the three R5 conditions is missing: an emergency justification, the matching migration file committed the same day, and the manual apply disclosed in the PR description.
Related docs
docs/security/SECURITY-INCIDENT-RPC-FUNCTIONS.md— the SECURITY DEFINER incident behind R4.docs/SUPABASE-PAGINATION.md— the other silent-data-corruption footgun (1,000-row cap).- Agent skill:
.agents/skills/database-migration-safety/SKILL.md— the trigger wrapper that routes coding agents here before they write a migration.