Supabase RLS audit checklist: what to verify before production
A practical Supabase RLS audit checklist covering enabled RLS, FORCE RLS, grants, policy scope, views, RPC functions, Storage, and regression checks.
Inside this guide
- Check RLS state and FORCE RLS table by table, not from memory.
- Treat grants, views, RPC functions, and Storage policies as part of the same exposure surface.
- Verify with anon/authenticated calls after every fix, then add a migration guard.
Start with database truth, not UI behavior
Most Supabase leaks look harmless in the app because the UI filters data before rendering it. Attackers do not use your UI. They call PostgREST, Storage, and RPC endpoints directly with the same public client credentials your frontend ships.
An RLS audit starts by proving what the database allows. For every sensitive table, record whether RLS is enabled, whether it is forced, which roles have grants, and which policies can read or write rows.
select
n.nspname as schema_name,
c.relname as table_name,
c.relrowsecurity as rls_enabled,
c.relforcerowsecurity as rls_forced
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r'
and n.nspname not in ('pg_catalog', 'information_schema')
order by 1, 2;Audit grants before policy details
Policies decide which rows pass. Grants decide whether a role can attempt the action in the first place. If anon or authenticated has broad table privileges, you have a larger public surface than most teams expect.
For backend-only data, revoke client-role access and keep privileged reads behind server routes. That design is usually easier to reason about than a large policy set exposed directly to the browser.
select
table_schema,
table_name,
grantee,
privilege_type
from information_schema.role_table_grants
where grantee in ('anon', 'authenticated')
order by table_schema, table_name, grantee, privilege_type;Review policies for ownership, tenant, and action scope
A policy that checks only auth.uid() is not enough for teams, organizations, workspaces, or invited-member flows. The expression must bind the row to the actor through a trusted ownership or membership relationship.
Separate read and write intent. SELECT policies, INSERT WITH CHECK clauses, and UPDATE USING/WITH CHECK clauses answer different questions. Mixing them into one broad policy makes reviews slower and regressions easier.
select
schemaname,
tablename,
policyname,
roles,
cmd,
qual as using_expression,
with_check
from pg_policies
order by schemaname, tablename, policyname;Do not stop at tables
Views, SECURITY DEFINER functions, public RPC execute grants, and Storage object policies can expose the same data even when base table RLS looks correct.
Trace the paths your product uses: browser Supabase client, server actions, API routes, Edge Functions, webhooks, background jobs, and downloads. Each path needs an explicit authorization boundary.
Turn the audit into a release gate
The goal is not a one-time cleanup. Add a recurring query or migration review that fails when a new sensitive table ships without RLS, FORCE RLS, or the intended grant posture.
After each remediation, test as anon, as an allowed user, and as a user from a different tenant. Save those examples with the migration so future reviewers know what the policy was supposed to prove.
FAQ
Should every Supabase table have RLS enabled?
For user or tenant data, yes. Public lookup tables may be intentionally readable, but the exception should be documented and tested.
Is FORCE ROW LEVEL SECURITY always required?
It is strongest for sensitive tables because it reduces owner-bypass surprises. Some maintenance paths may need service-role access instead.