← All posts
·6 min read·GuardLayer

DISABLE ROW LEVEL SECURITY on Supabase: what it exposes and the real fix

SupabaseRLSPostgresMigrations

On Supabase, every table in the public schema is reachable through the auto-generated PostgREST API using nothing but your anon key — the key that ships in your browser bundle by design. The only thing standing between an anonymous visitor and the rows in that table is Row Level Security. So when you run ALTER TABLE ... DISABLE ROW LEVEL SECURITY, you aren't tweaking an internal Postgres flag. You're removing the single gate that keeps the table private.

This almost always lands as a "fix." A dashboard query starts returning empty results or a 401/403, you're under pressure, you find a Stack Overflow answer that says "just disable RLS," and it works instantly. Of course it works — you turned off the lock. The query was blocked because no policy granted access, not because RLS was broken.

This isn't hypothetical — CVE-2025-48757. In May 2025, security researcher Matt Palmer disclosed 303 endpoints across 170 production apps with Supabase tables readable by anyone holding the public anon key — exposing emails, addresses, and even API keys — because Row Level Security wasn't protecting them. The fix was one line per table. Disabling RLS, as below, is that same failure made on purpose — and the mistake behind a wave of AI-built app data leaks.

What "disable RLS" actually does on Supabase

There are two states people constantly confuse:

  • RLS disabled — Postgres skips policy checks entirely. Any role with table privileges (on Supabase, the default grants cover anon and authenticated) can SELECT, INSERT, UPDATE, and DELETE every row. Policies on the table are ignored, even if they exist.
  • RLS enabled, no policies — Postgres applies policies, finds none that grant access, and denies everyone by default (except the table owner and roles that bypass RLS, like service_role). This is the locked-but-empty state. It's safe — just unhelpful until you add a policy.

The cure for "my query returns nothing" is almost always the second state plus a policy, not the first state. Disabling RLS doesn't add a permission; it removes the requirement to have one.

Why it's catastrophic, not just sloppy

Here's the line that turns a private orders table into an open API:

-- 20260619_fix_orders.sql
-- Quick fix so the dashboard query stops 403'ing.
alter table public.orders disable row level security;
guardlayer scan · supabase/migrations/20260619_fix_orders.sqlLive engine output
Merge blocked
75/100 · B
  • Criticalsupabase/migrations/20260619_fix_orders.sql:3

    Row Level Security disabled

    Re-enable RLS (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add policies that scope access with auth.uid().

Now anyone who can find your project URL — it's in your client bundle, it's not a secret — can run this from their laptop:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/orders?select=*" \
  -H "apikey: YOUR_ANON_KEY"

And get back every order: customer emails, amounts, addresses, internal status. Not just reads — with RLS off and Supabase's default grants in place, anon can write too, so the same endpoint accepts DELETE and PATCH. No login. No exploit. Just the documented REST API doing exactly what you told it to.

Three things make this worse than it looks:

  • It's silent. Disabling RLS produces no error, no deploy warning, no failing test. Your app keeps working perfectly. You find out when someone else does.
  • It overrides good policies. You can have carefully scoped auth.uid() = user_id policies sitting right there — disabling RLS makes Postgres ignore all of them. Your defense-in-depth evaporates in one statement.
  • It hides in migrations. A DISABLE buried in a 200-line migration gets rubber-stamped in review, runs once against production, and is never looked at again. The table stays open for months.

The Supabase dashboard does surface an "Unrestricted" badge on tables with RLS off, but badges get ignored, and a migration that ran weeks ago won't make anyone go check.

The fix

Don't disable RLS. Enable it and write the policy that grants the access your query actually needed.

-- Keep the gate ON.
alter table public.orders enable row level security;

-- Grant exactly what the dashboard needed: a user sees their own orders.
create policy "Users read own orders"
  on public.orders
  for select
  to authenticated
  using ( (select auth.uid()) = user_id );

auth.uid() returns the UUID of the authenticated user and null for the anon role, so anonymous callers fail the comparison and get nothing. Wrapping it as (select auth.uid()) lets Postgres evaluate it once per statement (an InitPlan) instead of once per row — Supabase's own recommendation for RLS performance on large tables. It does not change the security behavior.

If the dashboard query runs as a trusted backend, the right move is the service_role key on the server — it bypasses RLS deliberately and never reaches the browser — not flipping the table open for everyone. Use service_role only in a Route Handler or Server Action, never in client code.

If a table is genuinely meant to be world-readable (say a public blog_posts table), still keep RLS on and write an explicit read-only policy. That documents the intent and blocks accidental writes:

alter table public.blog_posts enable row level security;

create policy "Public can read posts"
  on public.blog_posts
  for select
  to anon, authenticated
  using (true);

The difference between this and a disabled table is enormous: this grants SELECT only, to anyone, on purpose. A disabled table grants everything — including writes and deletes — by accident.

30-second self-check

Run these against your project before your next deploy.

First, scan your migrations for the statement itself (POSIX-safe pattern):

grep -rniE 'disable[[:space:]]+row[[:space:]]+level[[:space:]]+security' supabase/migrations

Then ask Postgres directly which public tables currently have RLS off (run in the SQL editor):

select tablename
from pg_tables
where schemaname = 'public'
  and not rowsecurity;

Anything the second query returns is reachable by anon right now. If you didn't intend that table to be public, it's exposed. GuardLayer flags DISABLE ROW LEVEL SECURITY in your migrations on every push — before the statement ever reaches production.

FAQ

My query returns no rows / a 403. Will disabling RLS fix it? It "fixes" it the way removing your front door fixes a stuck lock. The real cause is a missing policy. Enable RLS and add a policy that grants the access you need — usually using ( (select auth.uid()) = user_id ).

Isn't the anon key secret? How would anyone reach the table? No. The anon key is public by design and lives in your client bundle, and so is the project URL. RLS is the protection layer, not the keys. With RLS off, the anon key is all an attacker needs.

I disabled RLS in dev only. Am I safe? Only if that migration never runs against production. Migrations are built to run everywhere. If DISABLE ROW LEVEL SECURITY is committed to your migrations folder, assume it will hit prod.

The table is supposed to be public. Do I still need RLS on? Yes. Keep RLS enabled and add an explicit for select ... using (true) policy. That makes it public for reads only, on purpose — and still blocks anonymous writes and deletes, which a disabled table allows.

I already shipped a disabled table. What now? Re-enable RLS immediately (alter table ... enable row level security;), add the correct policies, and treat the data as exposed for the entire window it was open. Check your logs for anomalous PostgREST traffic against that table.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading