← All posts
·5 min read·GuardLayer

Supabase Row Level Security: the Complete Guide

SupabaseRLSPostgresSecurity

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:

  1. 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.
  2. The service_role key 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:

guardlayer scan · supabase/migrations/20260701_orders.sqlLive engine output
Passed with warnings
92/100 · A
  • 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 to auth.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.

Keep reading