Fail the build when a migration opens a Supabase table: an RLS check for CI
Shaig Khaligli, security engineer
· 6 min read
The dashboard telling you RLS is enabled doesn't tell you your data is protected. A leftover USING (true) policy, a view, or a SECURITY DEFINER function can all hand rows to anyone with your publishable key while every table still shows the green "RLS enabled" badge.
That gets worse once an AI agent writes your migrations. It can add three tables in one session, and you won't read every policy. So the check needs to run on every change, automatically, and fail the build when something opens up. Below is a SQL script and a GitHub Actions workflow that do exactly that. I tested both against a throwaway Supabase project with five deliberate mistakes, and the output further down is from that run.
Key takeaways
- Run your migrations and seed in CI with
supabase db start, then run one SQL script that fails on exposure.- It checks for tables with RLS off, policies that allow every row, views and
SECURITY DEFINERfunctions that skip RLS, and what theanonrole can actually read.- Mark tables that are public on purpose with
comment on table ... is 'rls:public'so the check doesn't block them.- It catches accidental exposure. It doesn't test whether user B can read user A's rows, so keep a two-account test for that.
Why "RLS enabled" isn't enough
Row Level Security (RLS) is the Postgres feature that decides which rows each role can see. On Supabase it's the only thing between your publishable key and your users' data. Supabase's docs are clear that tables created in the Dashboard get RLS by default, but tables created in the SQL Editor or by migrations don't (Supabase docs).
Even with RLS on, four things still leak:
- A permissive policy.
using (true)on a SELECT policy makes the table public. It's usually added during development to get past an error, then forgotten. - Views. They bypass RLS by default, because they're created by the
postgresuser (Supabase docs). On Postgres 15+ you fix that withsecurity_invoker = true. - SECURITY DEFINER functions in
public. Anything inpublicis callable at/rest/v1/rpc/, and a definer function runs with its owner's rights. Supabase says never to create one in an exposed schema. - Policy logic that's wrong but not obviously wrong. No static check catches every bad condition, which is why the script also asks Postgres directly what
anoncan read.
The check
Save this as scripts/rls-guard.sql. It collects findings, prints them, and raises an error if there are any, which makes psql exit non-zero and fails the CI job.
-- RLS guard: fails when the database exposes data to the public API by accident.
-- Intentionally public tables: comment on table public.x is 'rls:public';
\set ON_ERROR_STOP on
\pset footer off
create temp table rls_findings (kind text, object text);
-- 1. Tables the API can reach with RLS switched off
insert into rls_findings
select 'RLS disabled', format('%I.%I', n.nspname, c.relname)
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
and not c.relrowsecurity
and (has_table_privilege('anon', c.oid, 'select') or has_table_privilege('authenticated', c.oid, 'select'));
-- 2. Policies that let every row through, on tables not marked public
insert into rls_findings
select 'policy allows all rows', format('%I.%I (policy "%s")', p.schemaname, p.tablename, p.policyname)
from pg_policies p
where p.schemaname = 'public'
and (p.qual = 'true' or p.with_check = 'true')
and coalesce(obj_description(format('%I.%I', p.schemaname, p.tablename)::regclass, 'pg_class'), '') <> 'rls:public';
-- 3. Views and materialized views that skip RLS
insert into rls_findings
select 'view bypasses RLS', format('%I.%I', n.nspname, c.relname)
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('v', 'm')
and has_table_privilege('anon', c.oid, 'select')
and coalesce(array_to_string(c.reloptions, ','), '') !~* 'security_invoker=(true|on)';
-- 4. SECURITY DEFINER functions anyone can call through /rest/v1/rpc
insert into rls_findings
select 'security definer callable by anon',
format('%I.%I(%s)', n.nspname, p.proname, pg_get_function_identity_arguments(p.oid))
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prosecdef
and has_function_privilege('anon', p.oid, 'execute');
-- 5. What anon can actually read right now (needs seed data to mean anything)
do $
declare
t record;
visible bigint;
leaks text[] := '{}';
begin
for t in
select format('%I.%I', n.nspname, c.relname) as name, c.oid
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p', 'v', 'm')
and has_table_privilege('anon', c.oid, 'select')
and coalesce(obj_description(c.oid, 'pg_class'), '') <> 'rls:public'
loop
execute 'set local role anon';
execute format('select count(*) from %s', t.name) into visible;
execute 'reset role';
if visible > 0 then
leaks := leaks || format('%s (%s rows)', t.name, visible);
end if;
end loop;
insert into rls_findings select 'anon can read rows', unnest(leaks);
end $;
select kind, object from rls_findings order by kind, object;
do $
declare
n int := (select count(*) from rls_findings);
begin
if n > 0 then
raise exception 'RLS guard: % finding(s). Fix them, or mark intentionally public tables with comment ''rls:public''.', n;
end if;
raise notice 'RLS guard: no findings.';
end $;
Check 5 is the one people usually skip. It switches to the anon role inside Postgres, the same role your publishable key uses, and counts the rows each table returns. That catches policies that look reasonable but still let rows through. It only means something if your supabase/seed.sql puts a few rows in each table, so add some if it doesn't.
Running it in GitHub Actions
supabase db start starts a local Postgres with Supabase's roles, applies everything in supabase/migrations, and loads your seed file. Then psql runs the check:
# .github/workflows/rls-guard.yml
name: RLS guard
on:
pull_request:
paths:
- "supabase/**"
- "scripts/rls-guard.sql"
jobs:
rls-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- run: supabase db start
- run: which psql || (sudo apt-get update && sudo apt-get install -y postgresql-client)
- run: psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/rls-guard.sql
Make the job required in your branch protection rules, and a migration that opens a table can't be merged. Locally, the same two commands work: supabase db start, then the psql line.
What it caught in the test project
I created a project with one correct table, one table that's public on purpose, and five mistakes: a table with no RLS, a leftover using (true) policy, a view over the protected table, and a SECURITY DEFINER function in public. This is the actual output:
kind | object
-----------------------------------+-------------------------------------
anon can read rows | public.notes (1 rows)
anon can read rows | public.orders (1 rows)
anon can read rows | public.order_totals (1 rows)
policy allows all rows | public.orders (policy "debug read")
RLS disabled | public.notes
security definer callable by anon | public.all_notes()
view bypasses RLS | public.order_totals
ERROR: RLS guard: 7 finding(s). Fix them, or mark intentionally public tables with comment 'rls:public'.
The correctly protected profiles table and the intentionally public posts table (marked with the comment) didn't show up. After a fix migration that enabled RLS, dropped the debug policy, set security_invoker on the view and revoked EXECUTE on the function, the check passed with RLS guard: no findings.
One detail worth knowing: revoking EXECUTE from public isn't enough on Supabase, because functions in public get it granted to anon and authenticated directly. Revoke from all three.
What this doesn't cover
This guards against accidental exposure to strangers. It doesn't replace:
- A two-account test. Whether a signed-in user can read or change another user's rows depends on your policy conditions. Sign in as user B and request user A's data.
- Columns users shouldn't write. An "update own row" policy still lets a user set
plan = 'pro'on their own profile. Column-level grants fix that. - Storage. Buckets have their own policies on
storage.objects, and public buckets skip them entirely. - Leaked keys. Check your built frontend for the secret key separately. We cover that in how to check if your Supabase secret key leaked.
The rest of what we check before launch is in the Supabase RLS checklist, and the SECURITY DEFINER guide goes deeper on functions. If you'd like someone to review the policies themselves, not just their exposure, that's what our premium scan is for.