PutThrough Product engineering studio
Book a build call

Audit & fix

Supabase RLS not working? A symptom-by-symptom checklist

Empty results, “new row violates row-level security policy”, updates that change nothing, or data anyone can read: each RLS symptom, its cause and its fix.

In short

When Supabase row-level security (RLS) seems not to work, the symptom tells you the cause. An empty result with no error means no policy lets that request read the rows, often because it isn’t signed in, so auth.uid() is null. “New row violates row-level security policy” means a WITH CHECK failed, or the insert asked for the row back without a select policy. Updates that change nothing need a select policy as well as an update policy. Data anyone can read means RLS is off, a view bypasses it, or a secret key reached the browser.

Key takeaways

  • RLS with no policies denies everything: you get empty results, not errors.
  • Returning an inserted row, updating, deleting and upserting all need a select policy too.
  • The Supabase dashboard runs queries as postgres, which bypasses RLS on the tables it owns. Test as a real user.
  • Views, security definer functions in exposed schemas and the secret key all bypass RLS.

First: are you testing as a real user?

Queries you run in the Supabase dashboard execute as the postgres role, which owns your tables, and in Postgres a table’s owner bypasses row-level security unless the table forces it. So a query that works in the SQL Editor proves nothing about your app. The app runs as anon before sign-in and as authenticated after.

Test the way the app runs. The Table Editor and SQL Editor can impersonate a role or a specific user, or you can do it in SQL, inside a transaction:

begin;
set local role authenticated;
set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001';
select * from public.notes;  -- only what this user may see
rollback;

Run it for two different users and once as anon. User A must never see user B’s rows, and a signed-out visitor should see only what you meant to be public.

Why does my query return an empty array with no error?

Because RLS denies by default. When it is enabled and no policy allows a row, Postgres leaves the row out, and there is no error to catch. The usual causes:

  • There is no select policy on the table yet.
  • The request isn’t signed in. Without an authenticated user, auth.uid() returns null, so a policy comparing it with user_id matches nothing. Typical triggers: querying before the session has loaded, or a server or Edge Function that creates its own client without passing on the user’s Authorization header.
  • The policy names the wrong role, such as to authenticated on data that signed-out visitors should see.
  • The rows have no owner: user_id is null because the insert never set it.

A read policy for rows that belong to the signed-in user:

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

Why do I get “new row violates row-level security policy”?

This error comes from a with check condition that failed, so it happens only on inserts and updates. Check three things:

  • There is an insert policy, and the row you send passes its check. If the check compares user_id with auth.uid(), the insert has to set user_id. It’s easier to make that the column’s default.
  • The insert asks for the row back (.insert(…).select() in supabase-js) but there is no select policy. Postgres checks returned rows against the select policies and throws an error instead of quietly leaving them out.
  • An upsert checks the insert, select and update policies, so it needs all three.
create policy "Users add their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );

alter table public.notes
  alter column user_id set default auth.uid();

Why does my update or delete change nothing?

Rows that fail an update or delete policy’s using condition are filtered out, not reported: the request succeeds and changes zero rows. And an update or delete that filters by a column has to read that column, so Postgres applies the select policies too. Supabase’s docs put it plainly: an update needs a matching select policy.

Give each operation its own policy, and ask for the changed rows back with .select() so your code can tell when nothing matched.

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

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

What does “infinite recursion detected in policy” mean?

Postgres raises error 42P17 when policies read each other in a loop. The classic case is an admin check: a policy on profiles that looks up the user’s role in profiles. Every lookup triggers the policy again.

Move the lookup into a security definer function. It runs with its creator’s rights, so it skips the policy. Put it in a private schema, never one your API exposes: there, anyone could call it with those rights.

create schema if not exists private;

create function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1 from public.profiles
    where id = (select auth.uid()) and role = 'admin'
  );
$$;

revoke execute on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

create policy "Admins read every profile"
on public.profiles for select
to authenticated
using ( (select private.is_admin()) );

Why can anyone read my data?

This is the dangerous one, because nothing looks broken. Your publishable (anon) key ships in your app’s JavaScript, and anyone who has it can query your tables directly. RLS is what stops them, and there are five ways around it:

  1. RLS is off on the table. Postgres creates tables with it off, and Supabase’s docs warn that a table in an exposed schema without RLS is readable and writable by any role with a grant on it.
  2. A policy allows everyone, such as using (true) for the anon role on a table that isn’t meant to be public.
  3. A view reads the table. Views bypass RLS by default; on Postgres 15 and later, set security_invoker = true on them.
  4. A security definer function sits in an exposed schema, where anyone can call it with its creator’s rights.
  5. The secret (service_role) key reached the browser. It bypasses RLS entirely. Search your built JavaScript for it, and if it’s there, move that call to a server and rotate the key.

Check Storage too: files in a public bucket can be read by anyone with the URL, whatever your policies say.

-- Tables in the public schema with RLS switched off
select tablename
from pg_tables
where schemaname = 'public' and not rowsecurity;

-- Switch it on (then add policies, or nothing is readable)
alter table public.notes enable row level security;

-- Make a view respect the policies of the tables it reads
alter view public.notes_summary set (security_invoker = true);

Why did queries slow down after adding policies?

A policy runs for every row a query touches. Supabase recommends three habits:

  • Wrap functions in a select: (select auth.uid()) rather than auth.uid(). Postgres then runs it once per statement instead of once per row.
  • Index every column your policies filter on, such as user_id.
  • Name the role with to authenticated, so the policy isn’t evaluated for requests it can never apply to.
create index on public.notes (user_id);

The checklist, in order

  1. List the tables in public with RLS off, and switch it on for every one.
  2. Give each table one policy per operation (select, insert, update, delete), with a role named and (select auth.uid()) in the condition.
  3. Default every owner column to auth.uid(), so inserts can’t leave it empty.
  4. Add a select policy wherever you return inserted rows, update, delete or upsert.
  5. Move any policy that reads another protected table into a security definer function in a private schema.
  6. Set security_invoker on every view.
  7. Review Storage: which buckets are public, and what the storage.objects policies allow.
  8. Search the built JavaScript for the secret key.
  9. Test as two different users and as a signed-out visitor.
  10. Index the columns your policies use.

What next?

Work through the checklist in order. If you’d rather have someone else do it, the AI-Built App Audit Report checks every table, policy, view, function and storage bucket with read-only access, and ranks what it finds by severity, in two business days. For everything else an AI-built app needs before launch, see From Lovable demo to production: a checklist.

About the author

Anubhav Mehrotra

Founder and principal engineer at PutThrough. Six-plus years shipping software end to end — Android and iOS apps, backends, frontends, infrastructure and AI agents.

Questions

Questions this raises

Something we haven’t covered?

Ask on a call
01 Is it safe to enable RLS before writing any policies?

Yes. With RLS on and no policies, nothing is readable or writable through the API with the publishable key. It breaks the app until you add policies, but it never exposes data.

02 Does the service role key respect row-level security?

No. The secret (service_role) key uses a role that bypasses RLS entirely, so it belongs only on a server, never in a browser or a mobile app.

03 Why does my query work in the SQL Editor but not in my app?

Dashboard queries run as the postgres role, which owns your tables and so bypasses RLS. Your app runs as anon or authenticated. Impersonate a user in the dashboard, or use set local role inside a transaction, to see what the app sees.

04 Do apps built with Lovable or Bolt need all of this?

Yes, if they use Supabase. The browser talks to the database directly with the publishable key, so RLS is the only thing between your data and anyone who opens the browser’s developer tools.

Have something to build?

A free 30-minute call. We’ll tell you honestly what fits in a week, and send a fixed quote afterward.

Prefer to write? Send your requirements

What do you need?

What it should do, who will use it, the must-haves, and any links. A few sentences is plenty.

Budget, if you have one in mind
When would you like to start?