BuiltSafely

Home/Fixes

Supabase

RLS is on, but my Supabase table still leaks

One using (true) or with check (true) policy opens a whole table, even with RLS on and an owner-only policy beside it. Find them with one pg_policies query and replace them with owner-scoped policies.

  • Verified: tested locally
  • Reviewed Oct 2026
  • 6 official sources
Sources Last checked October 26, 2026 · Badge: verified (tested on a throwaway local Postgres 17 with Supabase Auth and PostgREST, using two real accounts and a signed-out request) · Sources: official Supabase and PostgreSQL docs (linked below) · Educational, not an audit

RLS is switched on, the dashboard says the table is protected, yet one signed-in user can read (or change) another user's rows. The usual cause is a policy whose rule is always true: using (true) or with check (true). RLS is doing exactly what that policy says: let everyone in.

The fix

1. Find every always-true policy (Verified: run in the SQL editor or psql as the database owner. In our test it listed every always-true policy we planted, including a with check (true) insert policy and a (1 = 1) delete policy, skipped owner-scoped and restrictive policies, and returned 0 rows after the fix):

-- Permissive RLS policies whose USING or WITH CHECK is always true
select schemaname, tablename, policyname, cmd, roles,
       qual as using_expr, with_check as with_check_expr
from pg_policies
where schemaname not in ('pg_catalog', 'information_schema')
  and permissive = 'PERMISSIVE'
  and (
    lower(regexp_replace(coalesce(qual, ''), '[\s()]', '', 'g')) in ('true', '1=1')
    or lower(regexp_replace(coalesce(with_check, ''), '[\s()]', '', 'g')) in ('true', '1=1')
  )
order by schemaname, tablename, cmd;

How to read the result: using_expr is the rule for which existing rows a role can see, update or delete. with_check_expr is the rule for which rows it may write. The pg_policies view shows each policy's qual and with_check expressions as text (PostgreSQL: pg_policies). In our test, using (true) showed up as plain true (Verified). A roles value of {public} means the policy had no to clause, so it applies to every role, signed-out anon requests included. In our test, a select policy with no to clause let a signed-out request read every row (Verified). A query that only checks qual = 'true' misses insert policies, because those keep their rule in with_check (Verified).

Every row here is a table where that role can reach every row for that command. That's fine for data that's meant to be public (a product catalog, published posts). For anything private, go to step 2.

2. Replace the always-true policies with owner-scoped ones (Verified). Example for a notes table with a user_id column. Use your own table, column and policy names from step 1:

-- Remove the always-true policies (names come from step 1)
drop policy if exists "notes readable by everyone" on public.notes;
drop policy if exists "notes insert for signed-in users" on public.notes;
drop policy if exists "notes update for signed-in users" on public.notes;

-- Owner-only access (dropped first so this re-runs cleanly)
drop policy if exists "notes select own" on public.notes;
drop policy if exists "notes insert own" on public.notes;
drop policy if exists "notes update own" on public.notes;
drop policy if exists "notes delete own" on public.notes;

create policy "notes select own" on public.notes
  for select to authenticated
  using ( (select auth.uid()) = user_id );

create policy "notes insert own" on public.notes
  for insert to authenticated
  with check ( (select auth.uid()) = user_id );

create policy "notes update own" on public.notes
  for update to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

create policy "notes delete own" on public.notes
  for delete to authenticated
  using ( (select auth.uid()) = user_id );

These follow the patterns in Supabase's RLS guide: auth.uid() for the signed-in user, a to authenticated role, and (select auth.uid()) wrapped in a select for performance (Supabase: Row Level Security). Dropping the old policies matters. Adding a correct policy next to a using (true) one changes nothing (see below).

Tested October 26, 2026 on a throwaway local Postgres 17 with Supabase Auth (GoTrue) and PostgREST, the server behind Supabase's Data API, with two real accounts (A and B) and a signed-out request. Before the fix: signed out read all 3 notes, B read A's 2 notes, B inserted a note with A's user_id (201), and B edited A's note. After dropping the three always-true policies: signed out got [], B saw only B's note, B's insert as A failed with 42501 "new row violates row-level security policy", B's edit of A's note changed 0 rows, B could still create and edit B's own notes, and B couldn't hand a note over to A (42501). A saw only A's 2 notes.

3. If the data really is public, keep it read-only. Leave using (true) only on a for select policy, name the roles with to anon, authenticated, and keep writes owner-scoped. Supabase's RLS guide shows this shape for public profiles and announcements (Supabase: Row Level Security). The check in step 1 will keep listing that policy. That's expected. (Docs-only)

4. Re-run Security Advisor. Supabase's database linter has a lint, 0024_permissive_rls_policy, for this exact pattern (Supabase: Database Advisors). (Docs-only: we ran the query above, not the hosted Advisor.)

Why it happens

RLS decides access row by row, using the policies on the table. A policy's using rule picks which existing rows a role can see, update or delete. Its with check rule picks which new or changed rows a role may write (PostgreSQL: CREATE POLICY). If the rule is true, every row passes.

Policies are permissive by default. Postgres combines all permissive policies for a command "using the Boolean 'OR' operator" (PostgreSQL: CREATE POLICY). So one using (true) policy opens the whole table, even when a correct owner-only policy sits right next to it. That's why the table "has RLS" and still leaks (Verified: our test table had both).

Who it opens the table to depends on the roles. Supabase's RLS guide says a select policy for anon with using ( true ) "grants every unauthenticated visitor read access to every row the role can already reach through grants" (Supabase: Row Level Security). to authenticated doesn't mean "only the owner" either. It means any signed-in user, and the same guide notes that anonymous sign-ins also use the authenticated role.

Supabase's linter docs describe the always-true pattern as one that "defeats the purpose of having Row-Level Security enabled" (Supabase splinter: 0024 permissive RLS policy).

What not to do

  • Don't add a "correct" policy and leave the using (true) one in place. Permissive policies are OR'd, so the open one still wins.
  • Don't swap in with check (true) to get past a new row violates row-level security policy error. It lets a user write rows owned by someone else. Fix the real cause instead (new row violates RLS).
  • Don't disable RLS, and don't use the secret or service_role key in the browser to "make it work". Supabase says that key bypasses RLS (Supabase: API keys).
  • Don't assume to authenticated keeps strangers out. Anyone who can sign up is authenticated.

AI prompt

RLS is enabled on my Supabase tables, but users can see each other's rows.
Run this read-only query and show me the results:
select schemaname, tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname not in ('pg_catalog', 'information_schema')
  and permissive = 'PERMISSIVE'
  and (lower(regexp_replace(coalesce(qual, ''), '[\s()]', '', 'g')) in ('true', '1=1')
    or lower(regexp_replace(coalesce(with_check, ''), '[\s()]', '', 'g')) in ('true', '1=1'));
For each policy, tell me whether the table holds private data.
For private tables, propose SQL that drops the always-true policy and adds owner-scoped
policies using (select auth.uid()) = user_id, with with check on insert and update.
Do not disable RLS. Do not use a secret or service_role key in client code.
Show the SQL first and wait for my OK before applying anything.

How to verify (two accounts)

  1. Re-run the query from step 1. Only tables that are meant to be public should be left, and only with for select policies.
  2. Sign in as user A and create a row. Sign in as user B. Confirm B can't see A's row, and that B's own list shows only B's rows.
  3. As B, try to create a row with A's user_id, and try to edit A's row. Expect a 42501 "new row violates row-level security policy" error on the insert and 0 rows changed on the edit. (A 42501 "permission denied" is a different problem: a missing grant. See 42501 permission denied.)
  4. Sign out and query the table with just the publishable (anon) key. Expect [] for private tables.

Our Supabase RLS guide · 42501 permission denied · new row violates RLS · empty array with RLS on · is the publishable key safe?

Your turn

Did the query find a using (true) you didn't know about? Tell us which command it was on (select, insert, update or delete). We'll add the patterns we see most.

This guide is educational and based on the vendor's public docs as of the review date. Platforms change: check the linked sources. Not a security audit. Disclaimer.

The BuiltSafely weekly

One email a week. What changed, and the one fix that matters.

Platform changes that affect the security of AI-built apps, checked against official docs. Free. Unsubscribe any time.