Supabase RLS Not Working? Fix It by Symptom (5 Symptoms, 30-Second Check for Each)
Supabase RLS Not Working? Fix It by Symptom (5 Symptoms, 30-Second Check for Each)
"RLS not working" is four different bugs wearing the same coat. Which one you have determines the fix — and three of the four are config, not code. Here's the diagnostic table I use on every audit, symptom by symptom, each with its 30-second check.
Symptom 1: permission denied for table (42501) out of nowhere
You added RLS, and now the app gets permission denied for table users on a query that worked for months.
What it usually is: RLS is enabled but no policy matches your role/command. PostgreSQL's default is deny — enabling RLS without a policy that matches authenticated SELECT locks the door for everyone the row filter applies to.
30-second check:
select c.relname, c.relrowsecurity as rls_enabled, count(p.polname) as policy_count
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind = 'r'
group by c.relname, c.relrowsecurity;
rls_enabled = t and policy_count = 0 on the table your app just lost access to? That's the deny-default, not a bug in your policy.
Fix: add the policy you meant to write:
create policy "users read own row" on public.users
for select to authenticated
using (auth.uid() = id);
And a warning before anyone reaches for the big hammer: if the error is on a new table and it's late October 2026 or later, check the second half of this post first — Supabase stops auto-granting table privileges on Oct 30, and the correct fix is a scoped GRANT SELECT, INSERT, UPDATE, DELETE ON public.<table> TO authenticated; — not GRANT ALL ... TO public, which is how "fix the 42501" becomes "everyone can query my table."
Symptom 2: Rows come back that shouldn't — or the whole table downloads
The app "has RLS" (dashboard says enabled, policies exist), but user B can see user A's rows, or a single fetch returns everything.
What it usually is, in order of frequency:
- A policy with
USING (true)andTO anon/TO public— or noTOclause at all, which Postgres evaluates forpublicincludinganon. The policy name often says "Users can read…" — names don't execute, scopes do. - A login-helper pattern: someone needed "does this email exist on the login page" and generated
FOR SELECT TO anon USING (true)on the whole table — exposing every column (emails, phones, roles) when the intent was one lookup. - RLS never actually enabled — policies exist but sit there unenforced.
30-second check:
-- policies open to anon/public with no row predicate
select tablename, policyname, cmd, qual
from pg_policies
where roles::text in ('{anon}','{public}') and qual = 'true';
-- and: RLS actually enabled?
select relname, relrowsecurity
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and relrowsecurity = false;
Fix: scope the policy to who you meant — TO authenticated plus a row predicate (auth.uid() = user_id). For the login-helper, don't open the table at all: a SECURITY DEFINER function returning EXISTS(...) gives the login page its yes/no while the table stays sealed.
Real-world proof this class ships: I audited three production apps last week; every one had a variant of #1 or #2 — including a marketplace whose "Users can read all profiles" policy exposed every user's email to the anon key, and a dorm system whose staff table (phones included) was readable by anyone for the sake of a login email-check.
Symptom 3: Works in the SQL editor, breaks from the app
Same table, same query: Supabase SQL editor returns rows; the app gets an error or empty results.
What it usually is: you're testing as a role that bypasses RLS. The SQL editor runs as postgres — a role that can see everything. Your app runs as authenticated (or anon) with a JWT. Different role, different policies, different answer. Also: table owners bypass RLS unless FORCE ROW LEVEL SECURITY is set.
30-second check: test as the app, not as the owner:
begin;
set local role authenticated;
select set_config('request.jwt.claims',
json_build_object('sub', '00000000-0000-0000-0000-000000000000', 'role', 'authenticated')::text,
true);
select count(*) from public.notes; -- this is what your app sees
rollback;
The request.jwt.claims part is the one people skip: SET ROLE authenticated alone leaves auth.uid() NULL, every ownership policy filters everything away, and you conclude a correct policy is broken.
Symptom 4: Policy added, nothing changes
You added a policy and behavior is identical — still leaking, or still denied.
What it usually is:
- RLS itself was never enabled on that table (
alter table <t> enable row level security;) — policies without it are decoration. - You fixed the wrong role: policy is
TO authenticatedbut the leak comes throughanon(or vice versa). -
SECURITY DEFINERviews/functions in the path run as the owner and sidestep the policy entirely.
30-second check: re-run the Symptom 2 queries after your change, and read the roles column against the role that's actually misbehaving — not the one you assumed.
Symptom 5: WITH CHECK violations you didn't write
new row violates row-level security policy on INSERT/UPDATE, even though the user "owns" the row.
What it usually is: the USING clause is right but WITH CHECK is missing or too narrow — Postgres validates new row state against WITH CHECK, and for tables where ownership can change mid-write (admin edits, transfers), the new state falls outside the policy.
Fix: write both clauses deliberately — using (auth.uid() = user_id) with check (auth.uid() = user_id) — and for admin-override flows, a separate policy scoped to the admin role, not a broadened one for everyone.
The two-query annual physical
Run these two once a quarter and you'll catch symptoms 1, 2 and 4 before your users do:
-- everyone-open policies
select tablename, policyname, cmd from pg_policies
where roles::text in ('{anon}','{public}') and qual = 'true';
-- tables with RLS off (or on with zero policies)
select c.relname, c.relrowsecurity, count(p.polname)
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind = 'r'
group by c.relname, c.relrowsecurity
having c.relrowsecurity = false or count(p.polname) = 0;
If you'd rather automate the first pass: I put the same catalog behind a free 30-second scanner — paste a public repo URL, it reads migrations and env files entirely in your own browser (no database access, nothing stored) and prints the severity-ranked findings: https://rls.cenkkurtoglu.com/
And if it turns out you want a second pair of eyes on the whole surface — every table × policy, grants, roles, write-path — that's literally the service I run. Say hi: cenkkurtoglu@gmail.com.
All patterns above are from real audits of public repos; nothing here touches your database, and no client data appears anywhere in these examples.