Short answer
Empty results with no error almost always mean a missing select policy, or a request sent without
the user's login. Seeing every row usually means you're testing in the SQL editor (which skips RLS), using the
service_role key, or a using (true) policy is letting everyone in. Don't fix it by
turning RLS off.
RLS blocks my own app
RLS filters rows quietly. A query that isn't allowed to see anything doesn't fail; it just comes back empty. That's why these bugs are confusing.
Symptom: empty list, no error
1. RLS is on, but there's no select policy
With RLS on and no policy for reading, every select returns nothing. This happens a lot right
after an AI tool or the dashboard enables RLS without adding rules.
create policy "Owners read their rows" on public.notes
for select to authenticated using ((select auth.uid()) = user_id);
Symptom: "new row violates row-level security policy"
2. Insert fails because the new row doesn't pass the check
An insert policy's with check must be true for the new row. If your app doesn't set
user_id, it's empty and the check fails. Let the database fill it in:
alter table public.notes alter column user_id set default auth.uid();
create policy "Owners add their rows" on public.notes
for insert to authenticated with check ((select auth.uid()) = user_id);
Still failing on .insert(...).select()? Returning the new row also needs a select
policy that lets the user see it (cause 1).
Symptom: update or delete "works" but nothing changes
3. The user can't see the row they're changing
An update or delete can only touch rows the user is allowed to see. With no select policy, or
no update policy whose using rule matches the row, it changes zero rows and still
reports success. Add the select policy from cause 1, then:
create policy "Owners edit their rows" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
Symptom: works when logged in, empty on the server
4. The request isn't carrying the user's login
auth.uid() comes from the user's login token. In server code, edge functions or API routes, a
Supabase client created with only the public key runs as a stranger, so auth.uid() is null and
owner-only rules match nothing. Pass the user's token through (for example the request's
Authorization header) when you create the client there. Don't swap in the
service_role key to make it work: that skips RLS for everyone.
Symptom: "infinite recursion detected in policy"
5. A policy reads its own table
A common one: a policy on profiles checks whether the user is an admin by selecting from
profiles, which runs the policy again. Move the check into a function that runs with full rights,
kept in a schema your API doesn't expose:
create schema if not exists private;
create or replace function private.is_admin()
returns boolean
language sql stable security definer set search_path = ''
as $$
select exists (
select 1 from public.profiles
where id = (select auth.uid()) and role = 'admin'
);
$$;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;
create policy "Admins read all profiles" on public.profiles
for select to authenticated using ((select private.is_admin()));
Not sure which rules are wrong? The free Lockstamp check tries the attacks on a private copy in your browser and shows which ones get through.
Run the free checkRLS lets everyone in
This direction is the dangerous one, because nothing looks broken. Your app works, and so does anyone else's request for your users' data.
Symptom: "I can see every row, RLS is ignored"
6. You're testing in the SQL editor
The SQL editor runs as the postgres role, which skips RLS. Seeing everything there proves
nothing. Test as a real user instead. Newer Supabase dashboards have a role picker next to the
Run button: choose authenticated and a test user. Without it, run:
set role authenticated;
select set_config('request.jwt.claims', '{"sub": "TEST-USER-UUID", "role": "authenticated"}', false);
select * from public.notes; -- should show only this user's rows
Afterwards, run reset role; in the same SQL editor tab, or just reload the editor.
Symptom: the app sees every user's data
7. The app uses the service_role key
The service_role key (the secret key in newer projects) skips RLS completely. If it's in your
front-end code or a VITE_ or NEXT_PUBLIC_ environment variable, it's public. Swap it
for the public key in the browser, keep the secret key on the server only, and rotate it in your project's
API settings because it has already leaked.
Symptom: owner rule exists, but users still see each other's rows
8. Another policy says using (true)
Policies on the same table and action are combined with OR. One open rule beats any number of strict ones. List the open rules, then drop the ones on private data:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and (qual = 'true' or with_check = 'true');
drop policy "Enable read access for all users" on public.notes;
Symptom: the table is locked, but a view or function leaks it
9. A view or full-rights function skips the rules
Views run with their creator's rights unless set to security_invoker. A
security definer function in public can be called by anyone through the API. Make
views follow the caller's rules, and stop strangers calling functions that return private rows:
alter view public.my_view set (security_invoker = true);
revoke execute on function public.get_all_emails() from anon, authenticated, public;
If logged-in users need that function, keep their access and change it so it only returns the caller's own rows (for example where user_id = auth.uid()).
Symptom: a user made themselves an admin
10. Users can update columns they shouldn't
RLS decides which rows a user can change, not which columns. An "edit your own profile" rule also lets them
set role = 'admin' or plan = 'pro'. Limit which columns they can write:
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;
List every column your profile form saves, or those edits will start failing. If users create their own profile row, they could set role there too, so give it a default and limit the insert grant the same way. Or move roles and billing status to a table users can't write to at all.
A quick way to debug any policy
- List the table's rules:
select * from pg_policies where tablename = 'notes'; - Check RLS is on:
select relrowsecurity from pg_class where relname = 'notes'; - Run the query as the user, with the role picker or the SQL from cause 6, not as
postgres. - Check what the database thinks the user is:
select auth.uid();while still set to that user. - Change one policy at a time, and test both the user who should get in and one who shouldn't.
The last step matters most. A fix that makes your app work again can also let the wrong person in, and you only see that by testing as them. Our RLS checker guide shows how to test as a stranger and as a second user.