Your Supabase RLS is enabled and still wide open: the USING (true) trap
You did everything the docs told you. You ran ALTER TABLE ... ENABLE ROW LEVEL SECURITY. The dashboard shows a green "RLS enabled" badge. You wrote a policy. And yet every visitor can read every other user's rows through the anon key. RLS is on — and the table is wide open.
This is the most common reason people search "supabase rls not working." The cause is almost never that RLS is off. It's that a policy says USING (true), and true means everyone, always, no condition.
The risk is real — CVE-2025-48757. In May 2025, researcher Matt Palmer found 303 endpoints across 170 production apps leaking data to anyone with the public anon key because Row Level Security wasn't actually protecting the tables. A
USING (true)policy lands you in the same place by a different route: RLS is "enabled," but it grants everyone access.
What USING (true) actually does
A Postgres RLS policy is a row filter. For every row a query touches, the policy's expression is evaluated; if it returns true, the caller sees the row. USING decides which rows are visible (SELECT) or can be acted on (UPDATE/DELETE), and WITH CHECK validates the new row values on INSERT/UPDATE.
So when you write:
create policy "Profiles are viewable"
on public.profiles
for select
using (true);
you are telling Postgres: "for every row, return true." There is no condition. The policy never references auth.uid(), never looks at a user_id column, never checks a role. It evaluates to true for the anon role, for an authenticated stranger, and for a bot hitting your auto-generated PostgREST endpoint.
One subtlety that traps people: Postgres policies are permissive by default, and permissive policies are combined with OR. So a single USING (true) policy can't be "narrowed" by adding more permissive policies next to it — each one only adds access. Enabling RLS with a (true) policy is arguably worse than leaving it off, because the green badge makes you stop looking.
Here is the canonical version of the trap — a migration that enables RLS and then immediately neutralizes it:
-- 20260619_profiles_rls.sql
alter table public.profiles enable row level security;
create policy "Profiles are viewable"
on public.profiles
for select
using (true);
- Warningsupabase/migrations/20260619_profiles_rls.sql:4
Overly permissive RLS policy
Scope the policy to the requesting user, e.g. USING (auth.uid() = user_id). Reserve (true) for genuinely public, read-only data.
That's live engine output, not a mockup — GuardLayer flags the policy as overly permissive because the predicate is a constant.
Why it slips through every time
- It works in the demo.
using (true)is what most tutorials show first, because it makes the happy path light up immediately. People ship it and never come back. - The badge lies by omission. "RLS enabled" only means the gate exists. It says nothing about whether the gate is locked. A
(true)policy is an unlocked gate with a sign that says "secure." - Nothing errors. Your queries succeed, your app works, your tests pass. The only signal that something is wrong is someone reading rows they shouldn't — and you won't see that until it's a support ticket or a disclosure email.
- The anon key is public by design. It ships in your client bundle on purpose. The only thing standing between an anonymous visitor and your
profilestable is the policy. If that policy istrue, anyone who opens DevTools can paginate your entire table through the REST endpoint.
The fix: scope the predicate to the caller
Replace the constant with an expression that references the requesting user. For a table whose user_id column points at auth.users(id):
-- A user can only read their own profile row.
drop policy if exists "Profiles are viewable" on public.profiles;
create policy "Users read own profile"
on public.profiles
for select
using ( (select auth.uid()) = user_id );
auth.uid() returns the UUID of the currently authenticated user, and null for the anon role — which then fails the comparison, so anonymous callers get nothing. Wrapping it as (select auth.uid()) lets Postgres evaluate it once per statement instead of once per row (it becomes an InitPlan), a real performance win on large tables. It does not change the security behavior.
USING does not cover INSERT, and a SELECT policy does nothing for writes, so add explicit policies with WITH CHECK:
create policy "Users insert own profile"
on public.profiles
for insert
with check ( (select auth.uid()) = user_id );
create policy "Users update own profile"
on public.profiles
for update
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
A few patterns worth keeping in your head:
- Per-user ownership:
using ( (select auth.uid()) = user_id ) - Team / org membership: join through a membership table —
using ( exists (select 1 from members m where m.org_id = profiles.org_id and m.user_id = (select auth.uid())) ) - Role gate via JWT claim:
using ( (select auth.jwt() ->> 'role') = 'admin' )
When (true) is actually fine
There is exactly one legitimate use: genuinely public, read-only data. A countries lookup table, published blog posts, a list of feature flags meant for everyone — these can carry a for select using (true) policy. Two rules if you do this:
- Scope it to
for selectonly. Never give a(true)policy to INSERT, UPDATE, or DELETE —with check (true)lets any anonymous caller write to your table. - Make the intent explicit in a comment, so the next person (and your scanner) knows the openness is deliberate, not an oversight.
-- Public reference data, intentionally world-readable.
create policy "Countries are public" on public.countries
for select using (true);
30-second self-check
Run this against your project to find every constant-true policy you've shipped. qual and with_check hold the deparsed predicate, and a bare true deparses to the string 'true':
-- List policies whose USING / WITH CHECK predicate is just "true"
select schemaname, tablename, policyname, cmd, qual, with_check
from pg_policies
where qual = 'true' or with_check = 'true';
And catch it in your migrations before it ever reaches the database:
# Any policy with a constant-true predicate? (POSIX-safe pattern)
grep -rniE 'using[[:space:]]*\([[:space:]]*true[[:space:]]*\)|with check[[:space:]]*\([[:space:]]*true[[:space:]]*\)' supabase/migrations
If either returns a row that isn't intentionally public, you have an open table. Or scan the whole repo on every push — GuardLayer flags USING (true) policies (and 19 other Supabase + Next.js issues) and shows the scoped fix inline, before the migration ships.
FAQ
My RLS is enabled but everyone can still read the table. Why?
Almost certainly a policy with USING (true). Enabling RLS only adds the gate; the policy decides who gets through, and a true predicate lets everyone through. (If a table has RLS enabled but no policy at all, the default is the opposite — it denies everyone except the table owner and roles that bypass RLS.)
Is USING (true) ever safe?
Only for public, read-only data, and only on for select. Never pair WITH CHECK (true) with INSERT/UPDATE — that lets anonymous callers write arbitrary rows.
What's the difference between USING and WITH CHECK?
USING filters which existing rows a query can see or act on (SELECT/UPDATE/DELETE). WITH CHECK validates the new row values on INSERT/UPDATE. A policy can pass USING and still need a WITH CHECK to control writes — they are not interchangeable.
Does adding a scoped policy break my anon/public endpoints?
It restricts them to what the policy allows, which is the point. If a feature genuinely needs public read access, add an explicit for select using (true) policy for that table only, and document it.
Why wrap it as (select auth.uid()) instead of auth.uid()?
Postgres caches the subquery result for the whole statement (an InitPlan) instead of re-evaluating the function for every row, which noticeably speeds up RLS checks on large tables. It does not change the security behavior.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.