Disabling RLS in Supabase: what it exposes and the fix
Disabling Row Level Security on a Supabase table makes it fully public: with RLS off, anyone holding your anon key — which ships in every browser bundle — can read, insert, update, and delete every row through the auto-generated API. It's almost never the right fix for a blocked query; the real fix is to enable RLS and add a policy that grants the access you actually intended.
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. For the full roundup, see the documented Supabase security breaches.
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
anonandauthenticated) canSELECT,INSERT,UPDATE, andDELETEevery 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;
- 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_idpolicies sitting right there — disabling RLS makes Postgres ignore all of them. Your defense-in-depth evaporates in one statement. - It hides in migrations. A
DISABLEburied 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.
Why does Supabase ask you to choose whether to enable Row Level Security before running this query?
Because Studio inspects the SQL before it executes it. If your statement creates a table with no matching ENABLE ROW LEVEL SECURITY, the editor stops and opens a dialog titled "Potential issue detected" — plural, "Potential issues detected", when it finds more than one problem in the same query. For a single table the RLS block reads:
This query creates a table without enabling Row Level Security
Clients using anon or authenticated keys may be able to access
orders.Choose whether to enable Row Level Security before running this query.
The buttons are Cancel, Run without RLS, and Run and enable RLS. That pair of run buttons is specific to the RLS issue — the editor's other pre-run warnings offer a single Run query instead, because there's nothing for Studio to fix on your behalf. Nothing executes until you pick one. This is a gate, not a notice you can scroll past afterwards.
Two things are worth pinning down about when it appears. It is scoped to table creation: the editor parses for CREATE TABLE statements that have no corresponding enable, and that is the only RLS condition it looks for. And it exists because the SQL path has no default — Supabase's docs state that RLS is enabled by default on tables created with the Table Editor, and that if you create one in raw SQL or with the SQL editor you have to enable it yourself.
If you hit that modal while creating a table, the decision it's asking you to make is covered in every Supabase table needs RLS. The rest of this section is about the case that modal does not cover.
Does Supabase warn you when you disable RLS on a table that already exists?
No. The editor's pre-run check covers four cases: destructive statements (DROP, DELETE, TRUNCATE, ALTER TABLE ... DROP COLUMN), an UPDATE with no WHERE, an ALTER DATABASE that may prevent new connections, and CREATE TABLE without RLS. alter table public.orders disable row level security; matches none of them. No modal, no confirmation, no red text — it runs and reports success.
That asymmetry is the whole problem. The platform gates the moment a table is created unprotected, and does not gate the moment protection is removed from a table that had it. The one warning that does exist lives in a UI you're least likely to be in when it matters: a migration applied by supabase db push or by CI never renders the editor at all.
The dashboard isn't silent everywhere. In the Table Editor's table panel, the "Enable Row Level Security (RLS)" toggle carries a Recommended badge, and switching it off raises an alert headed "You are allowing anonymous access to your table", warning that the table will be publicly writable and readable. That's the click path. Take the SQL path — editor, migration, seed script, an agent running DDL for you — and you get nothing.
So the "it's silent" point above isn't a figure of speech. Between the statement running and someone noticing, the only thing that will tell you is something that reads live database state: the pg_tables query above, run deliberately, or the linter the dashboard runs for you.
What does "RLS Disabled in Public" mean in the Security Advisor?
It means a table in the public schema has RLS off. That's the entire condition — the lint doesn't distinguish between a table you never protected and one you disabled last Tuesday. It's rls_disabled_in_public, lint 0013, from Supabase's open-source linter (supabase/splinter), and it surfaces under Advisors → Security Advisor at level ERROR. The advisor's rationale:
Tables in the
publicschema are accessible over Supabase APIs. If row level security (RLS) is not enabled on apublictable, anyone with the project's URL can CREATE/READ/UPDATE/DELETE (CRUD) rows in the impacted table.
Each finding reads: Table public.orders is public, but RLS has not been enabled.
Two properties shape how useful it is. It reads live database state, so it can only flag a table that already exists in the project — by the time 0013 shows up, the endpoint has been open for as long as the migration has been applied. And the advisors run on their own schedule, though you can rerun them manually once you've fixed something.
If you disabled RLS on a table that already had policies, expect a second finding: "Policy Exists RLS Disabled" (policy_exists_rls_disabled, lint 0007). DISABLE ROW LEVEL SECURITY doesn't drop policies — they stay in pg_policy, unenforced. The advisor's wording: "Policies can be created, but will not be enforced until the table is updated to enable row level security."
That pairing is the linter's fingerprint for this exact failure. 0013 alone is usually a table nobody ever protected. 0013 plus 0007 on the same table is protection that was written, then switched off.
How do you fix "RLS Disabled in Public" without reopening the table?
Re-enabling RLS — the statement in the fix above — clears 0013, and clears 0007 too if policies survived the disable. What it does not do is restore access. The lint's own remediation says so: after enabling RLS you will not be able to use the anon role to read or write data to the table via Supabase APIs until you create policies.
So the advisor trades one finding for another: rls_enabled_no_policy (lint 0008, level INFO), reading "Table public.orders has RLS enabled, but no policies exist". That's the locked-but-empty state described earlier. It's the safe state, not a regression, and you clear it by adding the policy that grants the access your query actually needed.
If deny-all is the intent — a table only your server touches — write it down rather than leaving it implied. The 0008 documentation suggests exactly this pattern for a table that should permit no API access:
create policy none_shall_pass on public.orders
for select
using (false);
Two cautions specific to re-enabling. First, policies that outlived a DISABLE were never enforced while RLS was off, so they have never been exercised against real traffic. Read them before you trust them, and verify them as an anonymous caller — the SQL editor runs with privileges that bypass RLS, so re-running your query there proves nothing. Second, a using (true) policy clears 0013 and 0008 and leaves the advisor green while the table stays effectively public. A clean Security Advisor means no lint matched — not that your policies are correct.
The SQL editor let me run disable row level security without any warning. Doesn't that mean it's fine?
No — it means the editor's pre-run check doesn't look for that statement. Its only RLS condition is a CREATE TABLE with no enable. Silence there isn't approval. The table is reachable by anyone with your anon key from the moment the statement commits, and it will show up as "RLS Disabled in Public" the next time the Security Advisor runs.
The Security Advisor says "Policy Exists RLS Disabled" — I have policies, so why is it flagged?
Because policies do nothing while RLS is off. Disabling RLS doesn't delete them; Postgres simply stops evaluating them. That finding means your access rules are written and switched off — exactly the state this post describes. Fix it with alter table ... enable row level security;, then read the surviving policies before you rely on them.
I enabled RLS and now the advisor says "RLS Enabled No Policy." Did I make it worse?
No. That one is INFO, and it describes a closed table rather than an open one: RLS is on, nothing grants access, so the API returns no rows. Add the policy that grants what you actually intended and the finding clears. If the table is genuinely meant to be unreachable from the API, add an explicit using (false) policy so the intent is recorded instead of inferred.
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 ).
How do I enable RLS on a Supabase table?
Run alter table your_table enable row level security;, then add at least one policy that grants the access you need — for example create policy "Users read own rows" on your_table for select to authenticated using ( (select auth.uid()) = user_id );. Enabling RLS with no policy denies everyone by default, so the policy is the part that actually restores your query. You can also toggle RLS on from the Supabase dashboard (Authentication → Policies), but you still have to add the policy.
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 33 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.