Supabase RLS not working or returning an empty array: causes and safe fixes
Your query returns [] or an update silently does nothing. The usual causes (no matching policy, no signed-in user, wrong owner column, missing SELECT policy) and fixes that don't open the table to everyone.
Updated 2026-10-16
When Supabase returns [] for a table you know has rows, Row Level Security is usually working as designed: no policy matched the request, so Postgres showed nothing. The fix is to find out why no policy matched (no SELECT policy, no signed-in user, or the policy checks the wrong column) and correct that, not to switch RLS off or add using (true).
Supabase’s own troubleshooting note lists the same three causes. Below is how to tell which one you have, and a fix for each that keeps the table closed to strangers.
First, see what your app actually sees
The SQL editor bypasses RLS, which is why queries “work there”. Run the same query as your app’s role inside a transaction (nothing is saved):
begin;
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"PASTE-A-REAL-USER-UUID","role":"authenticated"}', true);
select auth.uid(); -- should print that uuid
select * from public.todos limit 5; -- what that user can see
rollback;Then list the policies on the table:
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and tablename = 'todos';Cause 1: there’s no SELECT policy
With RLS on, any action without a matching policy is refused. Postgres calls this default deny: no rows are visible. A table with only an INSERT policy accepts new rows and then returns nothing when you read it.
Fix: add a SELECT policy scoped to the owner.
create policy "todos: read own"
on public.todos for select to authenticated
using ((select auth.uid()) = user_id);Cause 2: the request has no signed-in user
If the query runs before the Supabase client has restored the session, or from a server without the user’s token, it goes out as anon. auth.uid() is then null, an owner check can never be true, and you get [].
How to tell: Supabase suggests calling a tiny function that returns the JWT next to the failing query:
create or replace function public.debug_whoami()
returns jsonb language sql stable as $$ select auth.jwt() $$;
-- In your app: const { data } = await supabase.rpc('debug_whoami')
-- role "anon" (or no sub) means no user session was sent.
-- Drop it when you're done:
-- drop function public.debug_whoami();Fix: wait for the session before querying (for example, run the query after supabase.auth.getSession() resolves or inside an onAuthStateChange handler), and on a server, create the Supabase client with the user’s access token rather than without one.
Cause 3: the policy compares the wrong thing
Common slips in AI-generated policies:
- Checking
idinstead ofuser_id(or the other way round). Onprofilesthe user’s id is usuallyid; on most other tables it’suser_id,owner_idorauthor_id. - The owner column is
textwhileauth.uid()is auuid. Make the columnuuid, or compare(select auth.uid())::text. - Rows were inserted without an owner (
user_idis null), so they can never match. Give the column a default:alter table public.todos alter column user_id set default auth.uid(); - The policy is
to anonbut the user is signed in (or vice versa).anonandauthenticatedare separate roles.
Cause 4: your update or delete “succeeds” but nothing changes
An UPDATE has to find the row first, and finding is a read. Supabase’s docs say an UPDATE needs a matching SELECT policy, and Postgres applies SELECT policies whenever an update reads the table (its where clause does). With no SELECT policy, the update matches 0 rows and returns no error.
Fix: make sure there’s a SELECT policy for the same user, and give the UPDATE policy both using and with check:
create policy "todos: update own"
on public.todos for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);The same applies to .insert(...).select(): returning the new row requires it to pass a SELECT policy. If it doesn’t, the insert typically fails with a new row violates row-level security policy error, the same message you get when the INSERT policy’s with check fails, so check both.
Cause 5: you’re reading through a view or a join
- Views run as their owner by default and bypass RLS. On Postgres 15 and later, create them with
with (security_invoker = true)so they respect the policies of the tables underneath. Then the view returns only what the user could read from those tables, which can be empty if one of them lacks a policy. - Embedded joins (
select('*, profiles(*)')) apply RLS to each table separately. Ifprofileshas no readable rows for this user, the nested part comes backnullor empty.
Cause 6: it really is a filter
Before blaming RLS, remove your .eq() / .match() filters one at a time. A filter that matches nothing also returns [].
Fixes that make things worse
| “Fix” | What it actually does |
|---|---|
alter table ... disable row level security | Anyone with your public key can usually read (and often change) every row. |
using (true) on private data | Same as above for reads, but harder to spot. |
to authenticated using (true) | Every person who signs up sees every row. Sign-up is free. |
Putting the service_role / sb_secret_ key in the frontend | Bypasses all RLS for everyone who opens your site. See what to do if that already happened. |
Copy-paste policies for profiles, posts, orders, messages and file uploads are in our Supabase RLS policy examples.
Prompt for your AI builder
My Supabase query on <table> returns an empty array. Do NOT disable RLS and do NOT
add "using (true)". Instead:
1. Show me the current policies on <table> (pg_policies).
2. Check the request is made with a signed-in user's session.
3. Make sure there is a SELECT policy comparing the owner column with
(select auth.uid()), and that UPDATE policies have USING and WITH CHECK.
4. Check the owner column is uuid and is filled in on insert.
Show me the SQL before running it.FAQ
My AI builder says to disable RLS to fix it. Should I?
Not on a live project. With RLS off, anyone with your public key can usually read the whole table. Supabase's troubleshooting page does suggest switching RLS off briefly as a diagnostic, so if you do it, do it on a development branch or a local copy, never on the database your users are on.
Is using (true) ever OK?
For data that's genuinely public, such as a product catalogue or published blog posts, a read-only using (true) is fine. For anything tied to a person (profiles, orders, messages, uploads) it publishes every row to the internet.
Why does my insert work but .insert().select() fail?
Returning the new row is a read. Postgres requires the inserted row to pass a SELECT policy before it can be returned, so add a SELECT policy for the owner or drop the .select().
Why does it work in the SQL editor but not in my app?
The SQL editor runs as a privileged role that bypasses RLS. Your app runs as anon or authenticated. Test as those roles (see the examples on this page) to see what your app sees.
I get permission denied for table instead of an empty array. Same problem?
No. That's error 42501 from missing table grants, which Postgres checks before RLS. This happens when grants were revoked, or the table lives in a schema where anon and authenticated were never granted access. Grant only what each role needs, for example grant select on public.posts to anon;, and keep RLS on.
Check it for free
Once your app works again, make sure the fix didn’t open anything. Our free scan tests what an anonymous visitor can read from your Supabase tables and storage (after you verify you own the app), and the RLS audit SQL lists every table with RLS off or a using (true) policy. Run a free scan.
Sealed — a short weekly security note for vibe coders
One common security mistake in AI-built apps, how to fix it in your builder, and one useful tool. No spam; unsubscribe any time.