Back to BlogSecurity & Compliance

"RLS Disabled in Public" in Supabase: What It Means and How to Fix It

Rupak Amin

Founder & Lead Engineer, RAITHub

13 min read

Supabase shows "RLS disabled in public" (lint 0013, level ERROR) when a table in the public schema has row-level security switched off. Anyone with your project URL and public key can then read, change and delete every row through the auto-generated API. Fix it by enabling RLS on the table, adding a policy for each operation your app needs, and testing as a second user.

This guide is for people who built on Supabase, often through Lovable, Bolt or Cursor, and have just seen the warning in the dashboard's Security Advisor. It gives the exact SQL, explains why the app may break the moment you apply it, and shows how to prove the fix instead of trusting it.

What does "RLS disabled in public" actually mean?

It means one of your tables is open to the internet. Supabase's lint 0013 documentation puts it plainly: "Anyone with your project URL can read, edit, and delete all data in this table because Row-Level Security is not enabled."

Three facts combine to make that true:

  • The public schema is exposed through the Data API. Supabase generates a REST endpoint for every table in it. Your app calls those endpoints straight from the browser.
  • The browser key is public by design. The publishable key (or the older anon key) sits in your JavaScript bundle, where anyone can read it. It is only safe when the database itself decides who may see which rows.
  • Row-level security (RLS) is that decision. RLS is a PostgreSQL feature that adds a condition to every query on a table, such as "only rows where user_id is the signed-in user". With RLS off, there is no condition, so every row is returned to every caller.

How the table got that way is usually simple. Supabase's tables guide says: "The Table Editor enables row level security for you when you create a table in the Dashboard. When you create a table with SQL, enable it yourself." Tables created by an AI tool, a migration or the SQL editor often skip that line.

How serious is an RLS-disabled table?

As serious as the data in it. If the table holds names, emails, orders or tokens, treat it as a live data exposure until it is fixed and you have checked the logs.

This exact class of flaw has a CVE. CVE-2025-48757 describes Lovable-generated projects "deployed with insufficient Row Level Security (RLS) policies", letting unauthenticated attackers read data using the public key. The advisory scores it 8.26 (High) and lists exposed personal data, third-party API keys and payment records. The researcher's statement on the CVE reports 303 vulnerable endpoints across 170 of 1,645 projects analysed, about 10.3%.

In the OWASP Top 10:2025, this is A01, Broken Access Control: the first item on the list. It is also not only a Lovable problem. Any app that talks to Supabase from the browser depends on RLS in the same way.

How do I find every table with RLS turned off?

Use the Security Advisor for the overview and one SQL query for the complete list. They should agree.

  1. Dashboard: open Advisors, then Security Advisor. Supabase's advisors documentation says to prioritise ERROR and WARNING findings, and to rerun the advisor after each fix. The same checks are available from the CLI as supabase db advisors.
  2. SQL: run this in the SQL editor to list every ordinary or partitioned table in public without RLS.
select c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
  and not c.relrowsecurity
order by 1;

Then list the policies that already exist, because a table can have RLS on and still be wide open:

select tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;

Look for qual or with_check equal to true on insert, update or delete. A policy of using (true) on a write is RLS in name only.

What SQL fixes "RLS disabled in public"?

The one-line fix from Supabase is alter table public.your_table enable row level security;. The complete fix is that line plus policies, and the policies depend on what the table is for. Sort each table into one of three kinds first.

Kind of tableExamplesPolicy
Owned by a userprofiles, notes, orders, messagesRead and write only rows where user_id matches the signed-in user
Public, read-onlyproducts, blog posts, pricing plansAnyone may read; nobody may write from the browser
Server-onlywebhook events, audit logs, internal settingsNo browser access at all; only server code with the secret key touches it

A table owned by a user

This follows the pattern in Supabase's row-level security guide, with one policy per operation, the role named with to, and auth.uid() wrapped in a select so Postgres evaluates it once per statement instead of once per row.

alter table public.notes enable row level security;

create policy "Owners can read their notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Owners can add their notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Owners can edit their notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Owners can delete their notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );

-- Policies filter on user_id, so index it.
create index if not exists notes_user_id_idx on public.notes (user_id);

The update policy needs both clauses. using decides which rows you may touch; with check decides what the row may look like afterwards. Without with check, a user could update their own row and set user_id to someone else's ID.

A public, read-only table

alter table public.products enable row level security;

create policy "Anyone can read products"
on public.products for select
to anon, authenticated
using ( true );

There is deliberately no insert, update or delete policy, so writes from the browser are refused. Admin edits go through server code.

A server-only table

alter table public.webhook_events enable row level security;
revoke all on table public.webhook_events from anon, authenticated;

With RLS on and no policy, PostgreSQL applies default-deny: its row security documentation says "no rows are visible or can be modified". The Security Advisor will show an INFO finding, "RLS enabled with no policy" (lint 0008), which is expected here. A cleaner long-term home for such tables is a schema that the Data API does not expose.

Why did my app stop working after I enabled RLS?

Because RLS is default-deny. The moment you enable it, every query from the browser returns nothing until a policy allows it. That is the safe failure, but it is visible to users, so plan for it.

  • Empty lists after enabling: the select policy is missing, or it is written for authenticated while the page loads before sign-in.
  • "new row violates row-level security policy": the insert has no policy, or the app does not send user_id. Setting the column's default to auth.uid() removes a class of these errors.
  • Updates that silently change zero rows: no update policy, or no select policy for the same rows.

The fix is to ship the enable line and its policies together, in one migration, tested on a local database or a staging project first. Do not run them one by one on production and hope the gap is short.

How do I test that my RLS policies actually work?

Test as the attacker would: as an anonymous visitor, and as a second signed-in user. One trap first. The SQL editor normally runs as the postgres role, which owns your tables, and PostgreSQL says table owners are "typically not subject to row security policies". A query that returns rows in the SQL editor proves nothing about what the browser sees.

Test 1: call the API as an anonymous visitor

Use the public key from your app, sent on the apikey header as Supabase's API keys guide recommends:

curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/notes?select=*" -H "apikey: YOUR_PUBLISHABLE_KEY"

For a user-owned table, the answer should be []. If you get rows back, the table is still open.

Test 2: a pgTAP test that runs on every change

Supabase runs database tests with pgTAP from the supabase/tests folder using supabase test db, and its testing guide shows how to impersonate users with set local role and a JWT claim. This test proves the three things that matter for the notes table:

-- supabase/tests/notes_rls.test.sql
begin;
create extension if not exists pgtap with schema extensions;
select plan(3);

-- Setup runs as postgres: two users, one note each.
insert into auth.users (id, email) values
  ('11111111-1111-1111-1111-111111111111', 'a@example.test'),
  ('22222222-2222-2222-2222-222222222222', 'b@example.test');
insert into public.notes (user_id, body) values
  ('11111111-1111-1111-1111-111111111111', 'A private'),
  ('22222222-2222-2222-2222-222222222222', 'B private');

-- 1. An anonymous visitor sees nothing.
set local role anon;
select is_empty('select * from public.notes', 'anon sees no notes');

-- 2. User A sees only their own note.
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select results_eq('select count(*) from public.notes', array[1::bigint], 'A sees one note');

-- 3. User A cannot change user B''s note.
select is_empty(
  $$ update public.notes set body = 'changed'
     where user_id = '22222222-2222-2222-2222-222222222222' returning id $$,
  'A cannot edit B''s note'
);

select * from finish();
rollback;

Run it with supabase test db against a local database. The transaction rolls back, so no test data is left behind. Write it before you fix the policy and watch it fail; that is the proof the test can catch the bug.

Test 3: two browsers

Sign in as user A in one browser and user B in a private window. In A's session, copy a record ID from the network tab. In B's session, request that ID. You should get nothing back. This catches mistakes in app code that a database test cannot see, such as a server route that uses the secret key and skips the owner check.

What other Supabase warnings usually come with this one?

Once one table was missed, others usually were too. Check these in the same pass.

  • Lint 0007, policy exists but RLS is disabled. Someone wrote policies but never enabled RLS, so the policies do nothing.
  • Lint 0010, security definer view. A view that runs with its owner's rights bypasses RLS on the tables under it. On Postgres 15 and later, set alter view public.your_view set (security_invoker = true); so the view obeys the caller's policies.
  • Lint 0023, sensitive columns exposed. Columns that look like personal or secret data in a table without RLS.
  • The secret key in the browser. A secret or service_role key has the BYPASSRLS attribute, so no policy applies to it. If one is in your front-end code, RLS is irrelevant until it is removed and rotated.
  • Storage buckets. Files have their own policies on storage.objects, and public buckets are readable by anyone with the URL.

For the wider list of production gaps in AI-built apps, beyond the database, see how to make a Lovable, Bolt or Cursor app production-ready.

How does RAITHub handle row-level security on its own platforms?

By making a missing policy a failed build rather than a finding. On Sundor Skin, a B2B wholesale platform RAITHub built, every buyer query runs under PostgreSQL row-level security across a 146-table core. CI replays all 76 migrations on an empty database and fails the build if any buyer-scoped table lacks a policy, and an IDOR suite among its 530+ tests tries to read other buyers' data.

Sundor Skin runs on PostgreSQL directly, not on Supabase, but RLS is the same PostgreSQL feature, and the discipline carries over: policies live in migrations, and tests attack them. RAITHub has not published a case study of fixing a Supabase or AI-built app, so this post is engineering guidance, not a claim of past rescues.

Why RAITHub for this

  • Policies with tests, not just policies. Each fix arrives with a test that fails before it and passes after, like the pgTAP example above, so the next generated migration cannot quietly reopen a table.
  • The app keeps working. Enabling RLS on a live app is where things break. The work includes finding every query the app makes and shipping the policies it needs in the same change.
  • A fixed written quote after a free 15-minute technical audit, an NDA before you share access, and the fix as a pull request you own.

When you don't need us

  • It is one or two tables with obvious owners, and the SQL above fits them. Apply it, run the three tests, and rerun the Security Advisor.
  • The app is a prototype with no real user data. Fix it now anyway, because it is cheaper than later, but you do not need outside help.
  • You need a certified security vendor or a formal penetration test. RAITHub is not SOC 2 or ISO 27001 certified and does not sell penetration testing.

If the advisor lists many tables, or the app uses the secret key in places you cannot trace, start from the fix one issue page. For a whole AI-built codebase, see Code Rescue.

Last reviewed: 29 September 2026. Supabase, PostgreSQL, OWASP and CVE sources checked on 29 September 2026.

To have the policies written and tested for you, pick Code Rescue on the contact form and paste the list of tables the query above returned.

Frequently asked questions

What does "RLS disabled in public" mean in Supabase?

A table in the public schema has row-level security turned off. Because Supabase exposes public tables through its API, anyone with your project URL and public key can read, edit and delete every row in it. Supabase rates it as an ERROR-level finding.

How do I enable RLS on a Supabase table?

Run alter table public.your_table enable row level security; and then create policies for each operation the app needs. Without policies, RLS denies everything, so ship both together in one migration.

Why does my app return empty data after enabling RLS?

RLS is default-deny. Until a select policy allows a row, queries from the browser return nothing. Add a policy scoped to the right role, usually authenticated with (select auth.uid()) = user_id.

Is it safe to leave RLS off if my app never shows that table?

No. The API does not care what your app shows. Anyone can call the table's endpoint directly with the public key. Enable RLS and add no policy for tables only server code should touch, or move them out of the exposed schema.

Why do my queries work in the SQL editor but not in the app?

The SQL editor usually runs as the postgres role, which owns the tables, and PostgreSQL table owners bypass RLS by default. Test with set local role inside a transaction, or through the API with the public key.

Does the Supabase service_role key respect RLS?

No. The secret and service_role keys bypass every policy. They belong only in server code, such as Edge Functions or your backend, and must be rotated at once if they ever reached the browser.

supabase rls disabled in publicrow level securitySupabaseRLS policiespgTAPLovabledatabase security

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.