Supabase "RLS Disabled in Public": how to fix it
If you open the Security Advisor in your Supabase dashboard and see RLS Disabled in Public (lint 0013_rls_disabled_in_public), Supabase is telling you that at least one table in your public schema has Row Level Security turned off. Supabase rates this lint as an ERROR, the highest level, and for good reason: a table in an exposed schema without RLS can be read and changed by anyone who has your project URL and public API key.
This guide explains why that is true even though "nobody knows my URL", how to list every affected table, and how to fix it in a way that keeps your app working. If you built your app with Lovable, Bolt, Cursor or another AI tool, this is the single most important setting to get right.
Why this warning matters
Supabase automatically generates a REST API (PostgREST) for every table in schemas it exposes, and public is exposed by default. Your frontend talks to that API with the anon key (or the newer sb_publishable_... key). That key is meant to be public: it ships inside your JavaScript bundle, so anyone who opens your site can copy it from the browser's developer tools in seconds.
That design is safe only because Row Level Security decides what each request can do. With RLS enabled, a request using the public key can only see or change the rows a policy explicitly allows. With RLS disabled, Postgres does not apply any row filter at all. If the anon role also has the default table grants, which most projects do, anyone can:
- Read every row with a request like
GET /rest/v1/profiles?select=* - Insert fake rows, including into tables your app treats as trusted
- Update or delete existing rows
None of this requires using your app's UI. Hiding a button, checking user.isAdmin in React, or filtering results on the client does nothing, because the attacker talks to the API directly.
"Nobody knows my project URL" is not protection either. The URL and key are in the JavaScript every visitor downloads. That is exactly how the issues described in CVE-2025-48757 were found across many Lovable apps: by reading the public bundle and then querying tables directly.
Step 1: Find every table with RLS off
The Security Advisor lists them, but it is useful to run the query yourself so you can see the whole picture. Open the SQL Editor in the dashboard and run:
select n.nspname as schema, c.relname as table_name, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('r', 'p')
and n.nspname = 'public'
order by c.relrowsecurity, c.relname;Every row with rls_enabled = false is a table the public key may be able to reach. While you are here, also check which roles have grants on those tables:
select table_name, grantee, string_agg(privilege_type, ', ') as privileges
from information_schema.role_table_grants
where table_schema = 'public' and grantee in ('anon', 'authenticated')
group by table_name, grantee
order by table_name, grantee;If you see anon with SELECT, INSERT, UPDATE, DELETE on a table with RLS off, that table is fully open to the internet.
Step 2: Decide what each table should allow
Before you write SQL, write one sentence per table describing who should do what. For example:
profiles: a signed-in user can read and update only their own row.posts: anyone can read published posts; only the author can create, edit or delete theirs.orders: a user can read their own orders; nobody can write from the browser (orders are created by a server webhook).app_settings: nobody from the browser; only server code.
This step matters because enabling RLS without policies blocks everything, and writing using (true) to "make it work again" puts you right back where you started. A policy that matches your sentence is the goal.
Step 3: Enable RLS and add matching policies
Here is a complete fix for a typical user-owned table. Replace notes and user_id with your table and owner column. Run it in one go so the app is never left with RLS on and no policies.
-- 1. Turn on Row Level Security
alter table public.notes enable row level security;
-- 2. Give each role only the operations it needs
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;
-- 3. One policy per operation, scoped to the owner
create policy "notes: owner can read" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "notes: owner can insert" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "notes: owner can update" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "notes: owner can delete" on public.notes
for delete to authenticated
using ((select auth.uid()) = user_id);A few details worth knowing:
usingfilters which existing rows a statement can see or touch.with checkvalidates the new row being written. Update policies usually need both, otherwise a user could changeuser_idto someone else's ID.- Wrapping
auth.uid()in(select auth.uid())lets Postgres evaluate it once per statement instead of once per row, which Supabase recommends for performance. - Revoking and re-granting is optional for RLS itself, but it is good hygiene. Policies decide which rows; grants decide which operations are possible at all. Supabase's own docs recommend setting both.
For a table that should be publicly readable but not writable, such as a product catalog:
alter table public.products enable row level security;
revoke all on table public.products from anon, authenticated;
grant select on table public.products to anon, authenticated;
create policy "products: anyone can read" on public.products
for select to anon, authenticated
using (true);using (true) is fine here because it is read-only and the data is meant to be public. Never pair using (true) with insert, update or delete.
For a table only your server should touch, enable RLS and add no policies. Your server code, Edge Functions and webhooks use the secret (service_role) key, which bypasses RLS, so they keep working while the browser gets nothing.
alter table public.app_settings enable row level security;
revoke all on table public.app_settings from anon, authenticated;More patterns (teams, admins, public profiles with private columns) are in Supabase RLS policy examples.
Step 4: Test it like an attacker would
The SQL Editor runs as a privileged role and ignores RLS, so a query there tells you nothing about what the public can see. Test with the public key instead. From a terminal, replace the project ref and key with your own values (both are already public in your app):
curl "https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=id&limit=1" \
-H "apikey: YOUR_ANON_OR_PUBLISHABLE_KEY" \
-H "Authorization: Bearer YOUR_ANON_OR_PUBLISHABLE_KEY"For a private table you should get [] (RLS filtered everything out) or a permission error, not data. Then sign in to your app as user A and confirm you cannot see user B's rows. Finally, re-run the Security Advisor and confirm lint 0013 is gone.
If you prefer automated tests, Supabase supports pgTAP tests via supabase test db, so a future migration cannot silently undo your policies.
Step 5: Stop it from happening again
Every new table is a new chance to forget RLS, and AI tools create tables constantly. Some habits that help:
- Ask your AI tool to include
enable row level securityand policies in the same migration that creates a table. A prompt you can paste:
For every table you create or change, enable Row Level Security in the same migration and add one policy per operation (select, insert, update, delete) that matches who should have access. Users may only access rows where user_id = auth.uid() unless I say otherwise. Never use "using (true)" for insert, update or delete. List each table and its policies when you are done.- Re-run the Security Advisor after every significant change, not just at launch.
- Check from the outside on a schedule. A table that was safe last week can change after a single prompt.
Common mistakes
- Enabling RLS with no policies, then panicking. The app goes blank because RLS denies everything by default. That is safe; add the policies rather than disabling RLS again.
- "Fixing" it with `using (true)` on everything. The Advisor warning disappears, but the table is still open. Supabase has a separate lint (
0024_permissive_rls_policy) for this. - Relying on a view. A view created as the default
postgresuser can bypass RLS on the underlying table. See Supabase security definer view warning. - Storing secrets in a table the browser can read. API tokens for third-party services belong in Edge Function secrets, not in a
settingstable.
Frequently asked questions
Is the "RLS Disabled in Public" warning serious if my app works fine?
Yes. The warning is not about whether your app works; it is about whether strangers can query the table directly with your public key. Supabase classifies it as an ERROR-level lint.
Will enabling RLS break my app?
It can, if you enable it without policies, because RLS denies all access by default. Run the enable statement and the policies together, then test the main flows in your app.
Can I just hide my anon key instead?
No. The anon or publishable key is designed to be public and has to be in your frontend for the Supabase client to work. Row Level Security and table grants are what protect your data.
Does the service_role key respect RLS?
No. The service_role key (and the newer secret keys) bypass RLS entirely, which is why they must only be used in server code you control, never in the browser.
I don't use the Supabase client in the browser at all. Do I still need RLS?
If your app only connects from a server, you can remove public from the exposed schemas in the API settings instead. If the Data API is exposed, enable RLS anyway; it is cheap insurance.