Supabase RLS Checklist: 12 Things to Verify Before Launch
Supabase itself is solid infrastructure. The apps built on it by AI coding tools are where things go wrong, and the failure is almost always the same one: the Data API is public by design, and row-level security is the only thing standing between it and your tables. Independent scans of AI-generated apps keep finding databases that anyone can read — one report put the critical rate for Lovable and Bolt apps at around 7%, against 0% for a control group of funded startups.
Work through these twelve before you ship.
1. RLS is enabled on every table in public
select relname, relrowsecurity
from pg_class
where relnamespace = 'public'::regnamespace and relkind = 'r';Any row where relrowsecurity is false is readable by anyone holding your anon key — which is everyone who opens your app.
2. Every RLS-enabled table has at least one policy
select c.relname, count(p.polname) as policies
from pg_class c left join pg_policy p on p.polrelid = c.oid
where c.relnamespace = 'public'::regnamespace and c.relkind = 'r'
group by 1 order by 2;Zero policies with RLS on means the table is inert. That is safe, but it is usually a bug: the feature silently returns nothing.
3. No policy expression is effectively true
using (true) on a user-data table is the most common AI-generated mistake. It passes a naive "RLS enabled" check and leaks everything.
4. Policies name a role
Write to authenticated (or to anon deliberately) rather than leaving the policy open to public. It makes intent reviewable.
5. Writes are checked, not just reads
using filters reads. with check constrains inserts and updates. A policy with using (user_id = auth.uid()) and no with check lets a user insert a row owned by someone else.
6. Grants exist
RLS is not enough. PostgREST needs table privileges:
grant select, insert, update, delete on public.notes to authenticated;
grant all on public.notes to service_role;Add grant select ... to anon only when a policy deliberately allows anonymous reads.
7. Roles live in their own table
Never store a role on profiles or any row the user can update. That is a one-line privilege escalation. Use a separate user_roles table with a unique (user_id, role) constraint.
8. Role checks go through a SECURITY DEFINER function
create or replace function public.has_role(_user_id uuid, _role app_role)
returns boolean language sql stable security definer set search_path = public as $
select exists (select 1 from public.user_roles where user_id = _user_id and role = _role)
$;This avoids the recursive-policy trap and keeps the check in one auditable place.
9. Views do not bypass policies
A view owned by a privileged role can read straight through RLS. Prefer security_invoker = true on views over the base tables.
10. Storage buckets have policies too
storage.objects is a table. Public buckets are public. Scope policies by path prefix so a user can only read their own folder.
11. The service-role key never reaches the browser
It bypasses RLS entirely. It belongs in server-side code only. If it has ever been in a client bundle or a commit, rotate it — you cannot un-publish a key.
12. Re-run the checks after every schema change
A new table added by an AI edit arrives with RLS off unless the migration says otherwise. Freshness matters: evidence from three weeks and eleven migrations ago tells you nothing about today.
Fix order
Public tables holding personal data first, then write policies, then grants, then role plumbing. Fix the category, not the instance — if one table shipped without policies, check the siblings created in the same migration.
Sentrail maps your Supabase project, runs these checks against the live schema, and records the policy text it saw so a passing verdict can be verified rather than trusted.
