You shipped a Supabase backend for a mobile app, flipped on Row Level Security in the dashboard for a handful of tables, and now want to verify from a terminal exactly which tables have RLS enabled and which policies are live. The Supabase Studio hides the raw policy SQL for some policy types, so the cleanest audit path is to query Postgres directly with psql and two built-in catalog views.
Short answer
Connect to your Supabase Postgres database with psql, then read two system catalogs. pg_class.relrowsecurity confirms which tables have ENABLE ROW LEVEL SECURITY set. The pg_policies view returns every active policy with the full USING and WITH CHECK expressions. Combined, the two queries form a one-minute RLS audit you can run against any Supabase project, save as a script, and re-run before each mobile release.
What you should know
- The Supabase Studio dashboard does not always show raw policy SQL. Querying the database directly is the only way to read every CREATE POLICY in full.
- ENABLE ROW LEVEL SECURITY and CREATE POLICY are independent states. A table can have policies attached and still leak data if
relrowsecurityis false on that table. - pg_policies is a built-in Postgres view. It aggregates
pg_policydata and exists in every Supabase project with no extension required. - The psql
\d+ tablenamemeta-command lists policies in a human-readable block next to columns, constraints, and indexes. - The Supabase CLI surfaces RLS gaps automatically. The
supabase db lintcommand and the database advisor lints flag tables in thepublicschema that have RLS disabled.
How do I connect psql to a Supabase project from my terminal?
The short answer is to grab the connection string from Project Settings, Database in the Supabase dashboard, then pass it to psql. Supabase exposes the pooled connection on port 6543 for transactional queries and a direct or session-mode connection on port 5432 for migrations and meta-command sessions.
For an audit, the session-mode connection is the right choice. Catalog reads complete in milliseconds and a session-mode connection lets you run psql meta-commands like \d+, which the transaction pooler does not support.
psql "postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres"
Replace <project-ref> with the project reference from your dashboard URL, <password> with the database password (not the anon or service role JWT), and <region> with the project region. Supabase's database connection guide covers the IPv4 add-on, IPv6 defaults, and the difference between the session pooler and the transaction pooler.
A common failure mode at this step is using the anon or service role key as the password. The anon and service role keys are JWTs for the PostgREST API. They do not authenticate at the Postgres protocol level. The database password lives separately in Project Settings, Database and must be stored alongside the connection string in your .env.
Which tables actually have Row Level Security turned on?
The honest answer is that having a policy attached to a table is not the same thing as having Row Level Security enabled on that table. Postgres tracks the two states in different system columns, and a table with CREATE POLICY statements but relrowsecurity = false is wide open to any role without BYPASSRLS restrictions.
To list every table in the public schema and confirm RLS status in one read:
select 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 n.nspname = 'public'
and c.relkind = 'r'
order by c.relname;
Per the PostgreSQL row security documentation, relrowsecurity is the boolean set by ALTER TABLE ... ENABLE ROW LEVEL SECURITY, and relforcerowsecurity is the stricter flag that subjects the table owner to policies as well. The service_role JWT used by Supabase Edge Functions inherits the BYPASSRLS attribute, so even forced RLS does not stop a service role call. That matters when reasoning about which reads in your mobile app actually pass through the policy layer.
Any row in this query that returns rls_enabled = false is the audit's first finding. Tables that hold user data in the public schema and ship with RLS off are a recurring source of Supabase data exposures reported to public bug bounty programs.
How do I list every active policy with pg_policies?
The short answer is one select against pg_policies. The view ships with Postgres and is documented in the PostgreSQL pg_policies reference. It exposes the policy name, the table, the command it applies to, the roles, and the full USING and WITH CHECK expressions as text.
select schemaname,
tablename,
policyname,
permissive,
roles,
cmd,
qual as using_expression,
with_check
from pg_policies
where schemaname = 'public'
order by tablename, policyname;
The output reads as a full CREATE POLICY catalog. The cmd column tells you whether the policy applies to SELECT, INSERT, UPDATE, DELETE, or ALL. The roles column tells you which Postgres role the policy targets, typically authenticated or anon in Supabase. The qual column carries the USING expression as a string, which filters rows on read. The with_check column carries the predicate that runs on INSERT and UPDATE, which prevents a user from writing rows they cannot read.
A row with cmd = 'ALL', roles = {authenticated}, and qual = 'true' is the pattern that looks like a policy but allows every authenticated user to read every row. That pattern is worth grepping for explicitly in the output of the query above.
What does the \d+ command show that pg_policies does not?
The honest answer is the same data in a more human-readable layout. The psql meta-command \d+ public.your_table prints columns, indexes, foreign keys, and a Policies block that lists every policy attached to the table with its predicate inline.
\d+ public.profiles
Policies:
POLICY "Users can read their own profile"
FOR SELECT
TO authenticated
USING ((auth.uid() = id))
POLICY "Users can update their own profile"
FOR UPDATE
TO authenticated
USING ((auth.uid() = id))
WITH CHECK ((auth.uid() = id))
The advantage of \d+ is that you read RLS in context with the table shape. The disadvantage is that you have to run it per table, which is fine for a hot-spot review and not for a project-wide audit. The table below compares the three terminal approaches by what each is best for, what they miss, and how fast they run.
| Approach | Best for | Misses | Speed |
|---|---|---|---|
select * from pg_policies | Project-wide audit, scripting | Policy comments, table indexes | One query, all tables |
\d+ tablename | Per-table deep read | Cross-table summary | One table per run |
supabase db lint | Drift detection, CI gates | Full policy expression text | Whole-project scan |
How do I run the same audit through the Supabase CLI?
The short answer is supabase db lint. The Supabase CLI ships a linter that runs the same lint set as the dashboard's database advisor. The lint rls_disabled_in_public flags any table in the public schema with relrowsecurity = false. The lint multiple_permissive_policies flags tables where two permissive policies for the same role and command create a logical OR, which is rarely the intended behavior.
supabase db lint --schema public --level warning
The CLI lint output is plain text and exits non-zero when issues are found, which makes it easy to wire into a pre-commit hook or a CI step. For builders shipping a mobile client that talks to Supabase over the anon key, running this lint on every PR catches the regression where a new table lands in public with no RLS attached.
The CLI also ships supabase inspect db subcommands such as inspect db role-connections and inspect db long-running-queries, which are useful around release time but do not surface policies directly. For policy inspection from CI, the pg_policies query above piped through psql -c is the most direct route.
For builders who want an external scan of their compiled mobile build alongside the Supabase audit, including a check for hardcoded service role keys, exposed anon keys in the bundle, and other patterns that bypass RLS at the client layer, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission scanning aligned with OWASP MASVS for no-code and vibe-coded apps.
How do I test that a policy actually blocks the right reader?
The short answer is to set the role and the JWT claim inside a transaction, run the query as that user would, then roll back. Supabase's local testing guide shows the pattern with pgTAP, and a manual psql session does the same job for a quick check.
begin;
set local role authenticated;
set local "request.jwt.claim.sub" = '00000000-0000-0000-0000-000000000001';
select * from public.profiles;
rollback;
The set local role authenticated switch tells Postgres to evaluate the next query as if the request came from the authenticated role, which is the role Supabase assigns to a signed-in user. The set local "request.jwt.claim.sub" line populates the claim that Supabase policies typically read via auth.uid(). If the query returns rows that the user with that UUID should not see, the policy has a bug.
The cleanest version of this test lives in a .test.sql file run by supabase test db, which is the path that keeps a regression suite around RLS as the schema evolves. Per the Supabase testing docs, pgTAP assertions like results_eq and throws_ok let you encode positive and negative cases for every policy.
What to watch out for
A few traps catch teams more than once on a Supabase RLS audit.
The policy-attached-but-RLS-off trap is the most expensive. A developer adds a CREATE POLICY statement, the policy appears in the dashboard, and the developer never runs ALTER TABLE ... ENABLE ROW LEVEL SECURITY. The result is that the policy is dormant. Every read against that table bypasses the policy and returns every row to every authenticated client. The fix is the pg_class.relrowsecurity query at the start of every audit.
The service-role-in-the-client trap is the second. Supabase's service role key carries BYPASSRLS, so any code path that calls Supabase with the service role JWT ignores every policy you wrote. The service role key must never be bundled into a mobile app. If grep -ri "service_role" ios/ android/ returns hits in your build directory, the audit is not done.
The anon-versus-authenticated confusion is the third. The default anon role is the role used by signed-out clients. A policy that targets authenticated does not apply to anon, and a table with only authenticated policies and RLS enabled returns zero rows to an anon caller. That looks like a working policy in the dashboard. The risk lives in the inverse case: a policy attached TO public or TO anon with USING (true) is open to the whole internet.
The multiple-permissive-policies trap is the fourth, and it is the one the Supabase advisor lints flag explicitly. Two permissive policies for the same role and command combine with OR, not AND. Splitting a single restrictive condition into two permissive policies typically widens access rather than narrowing it.
Key takeaways
- Run two queries on every Supabase project before submission: the
pg_class.relrowsecurityaudit for thepublicschema, andselect * from pg_policiesfor the full policy catalog. - Treat ENABLE ROW LEVEL SECURITY and CREATE POLICY as separate states. A policy on a table with RLS disabled is dormant and silently returns every row.
- Wire
supabase db lintinto CI so that a new table without RLS fails the build, not the App Store reviewer's first look at the data your client returns. - Use
set local roleandset local "request.jwt.claim.sub"inside a transaction to verify policies block the reader you think they block, then roll back. - Some teams pair the database-side audit with an external scan of the compiled APK or IPA to confirm no service role key shipped in the bundle. PTKD.com (https://ptkd.com) is one of the platforms focused on that pre-submission check.



