Security

    How do I check Supabase RLS policies from the terminal?

    Terminal showing a psql session connected to a Supabase Postgres database, listing Row Level Security policies from the pg_policies catalog view in the public schema

    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 relrowsecurity is false on that table.
    • pg_policies is a built-in Postgres view. It aggregates pg_policy data and exists in every Supabase project with no extension required.
    • The psql \d+ tablename meta-command lists policies in a human-readable block next to columns, constraints, and indexes.
    • The Supabase CLI surfaces RLS gaps automatically. The supabase db lint command and the database advisor lints flag tables in the public schema 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.

    ApproachBest forMissesSpeed
    select * from pg_policiesProject-wide audit, scriptingPolicy comments, table indexesOne query, all tables
    \d+ tablenamePer-table deep readCross-table summaryOne table per run
    supabase db lintDrift detection, CI gatesFull policy expression textWhole-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.relrowsecurity audit for the public schema, and select * from pg_policies for 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 lint into 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 role and set 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.
    • #supabase
    • #row level security
    • #rls
    • #postgres
    • #psql
    • #pre-submit
    • #ai security

    Frequently asked questions

    Do I need superuser access to read pg_policies on Supabase?
    No. The Supabase postgres role used in the dashboard connection string can read both pg_policies and pg_class. The view returns the policies the calling role has visibility into, which for postgres is every policy in the database. Custom read-only roles created later only see policies on tables they own or have privileges on, so audits should run as postgres.
    Can I see RLS policies through the Supabase Management API instead of psql?
    The Supabase Management API exposes a project query endpoint that accepts arbitrary SQL with a personal access token. Sending the same select against pg_policies through that endpoint returns identical results to a psql session and is the right call for CI scripts that cannot install psql. The Database, Policies page in Studio does not expose the raw SQL through the public API in every case.
    Does the Supabase Studio dashboard show all policies?
    The Studio dashboard at Database, Policies lists every policy by name, target role, and command. It hides the raw SQL of the USING and WITH CHECK expressions behind a modal and does not let you copy the full policy as a CREATE POLICY statement in every case. A pg_policies query in psql returns the same metadata plus the full expressions as plain text.
    What is the difference between relrowsecurity and relforcerowsecurity?
    relrowsecurity is set to true by ALTER TABLE ENABLE ROW LEVEL SECURITY and applies policies to every role except the table owner and roles with BYPASSRLS. relforcerowsecurity is set by ALTER TABLE FORCE ROW LEVEL SECURITY and adds the table owner to the set of roles policies apply to. The Supabase postgres role owns most tables, so FORCE matters when other roles connect.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free