Supabase Row Level Security: the Complete Guide
Row Level Security (RLS) is the only thing standing between an anonymous visitor and the rows in your Supabase tables, because every table in the public schema is reachable through the auto-generated API using the anon key that ships in your browser. Enable it on every table (alter table ... enable row level security) and add explicit policies scoped to auth.uid() — a table with RLS off, or a policy of using (true), is effectively public.
Supabase is Postgres with an auto-generated REST and realtime API in front of it. That convenience is also the trap: the moment you create a table in the public schema, it's queryable over the internet with nothing but your anon key — the key that is, by design, embedded in every browser bundle you ship. Row Level Security is the gate that decides which rows each request may see. Get it right and Supabase is genuinely secure by default. Get it wrong and you've published your database.
This guide is the map for the whole topic. Each mistake below links to a focused deep-dive with the exact fix.
This isn't hypothetical — CVE-2025-48757. In May 2025, researcher Matt Palmer disclosed 303 endpoints across 170 production apps with Supabase tables readable by anyone holding the public anon key — emails, addresses, even API keys — because RLS wasn't protecting them. The official record scores it CVSS 9.3. The fix was one line per table.
How does Supabase RLS actually work?
RLS is a Postgres feature: when a table has RLS enabled, every query is filtered through policies you define, and any row that doesn't match an allowing policy is invisible — as if it didn't exist. With RLS enabled and no policies, the table denies all access (fails closed). With RLS disabled, Postgres skips the check entirely and the table is fully open through the anon key.
Two facts follow that trip everyone up:
- Enabling RLS with no policy = locked. Your app suddenly gets empty results or 403s. That's RLS working — you just haven't granted the access you intended yet.
- The
service_rolekey ignores RLS completely. It's meant for trusted server code. If it ever reaches the browser, every policy you wrote is bypassed at once.
Enabling RLS and writing your first policy
Turn it on for the table, then grant exactly the access you mean:
alter table public.orders enable row level security;
-- Each user can read only their own orders.
create policy "read own orders"
on public.orders for select
using ( auth.uid() = user_id );
-- ...and create orders only for themselves.
create policy "insert own orders"
on public.orders for insert
with check ( auth.uid() = user_id );
The two clauses matter: using filters which existing rows a query can see (SELECT/UPDATE/DELETE), while with check validates rows being written (INSERT/UPDATE). A policy without with check on inserts lets a user write rows attributed to someone else.
Here's the opposite — a real migration that creates a table and never enables RLS at all. This is live GuardLayer engine output on that file, not a mockup:
- Warningsupabase/migrations/20260701_orders.sql:2
Table created without enabling RLS
Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.
The four mistakes that leak a Supabase table
Almost every Supabase data leak is one of these:
- RLS never enabled / disabled to "fix" a query. The table is public. The blocked query was blocked because no policy granted access — not because RLS was broken.
- A table created without RLS. New tables aren't protected until you turn it on; it's easy to add one and forget.
- The
using (true)trap. A policy that matches every row. RLS is "on," the dashboard looks green, and the table is still world-readable. - A policy that isn't user-scoped.
using (auth.role() = 'authenticated')lets any logged-in user read everyone's rows — the classic multi-tenant leak. Scope toauth.uid() = user_id.
Edge Functions are a separate surface: they often run with elevated access, so an unauthenticated Edge Function can sidestep RLS the same way the service_role key does.
How do I check if my Supabase RLS is correct?
A fast audit you can run today:
-- Tables in `public` with RLS turned OFF — each one is exposed:
select relname
from pg_class
where relnamespace = 'public'::regnamespace
and relkind = 'r'
and relrowsecurity = false;
Then, for tables that do have RLS on, read each policy and confirm its using clause references auth.uid() (or a join that ultimately does), not true or a bare authenticated check. Anything that returns rows for a user who shouldn't see them is a leak.
Or scan the whole repo at once: GuardLayer reads your migrations and flags RLS-off tables, using (true) policies, and non-user-scoped policies on every push, with the exact SQL fix inline.
FAQ
Do I need RLS if my app has a login? Yes. Auth proves who is calling; RLS decides which rows they may touch. Without RLS, any authenticated user (or anyone with the anon key) can query every row directly through the API, bypassing your app code entirely.
Is it safe that my anon key is public? Yes — as long as RLS is enabled and your policies are correct. The anon key is designed to be public; RLS is what constrains it. The service_role key is the one that must never be public.
Does enabling RLS with no policies make a table private? Yes. RLS fails closed — with it enabled and no policy, the table denies all access. You then add policies to grant exactly the access you intend.
Can the service_role key be filtered by RLS?
No. service_role bypasses RLS by design. Keep it server-side only and never hand it to the browser or an AI agent.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.