Scan free

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

"Supabase RLS disabled in public" means a table in your public schema has Row Level Security turned off, so anyone with your project URL and public API key can read it, and usually change or delete its rows, through the API Supabase generates for you. It is serious. The fix is one SQL statement per table, plus a few policies that say who may see which rows.

This post shows how to confirm the problem on your own project, turn RLS on without breaking the app, and keep it from coming back.

What "RLS disabled in public" means

Supabase exposes every table in the public schema through its Data API at https://<project_ref>.supabase.co/rest/v1/. Row Level Security is the Postgres feature that decides which rows each request may touch. With it off, the API applies no row rules at all, so any request carrying your public key can read the whole table, and usually write to it too.

The warning comes from the Security Advisor in your Supabase dashboard. Its lint id is 0013_rls_disabled_in_public ("RLS Disabled in Public"), and Supabase's docs now title it Table publicly accessible. Each flagged table gets a line like "Table public.profiles is public, but RLS has not been enabled." It is level ERROR, the most severe level the linter uses. Supabase's own explanation of the lint is blunt: anyone with the project's URL can create, read, update and delete rows in that table.

Is the public key really public?

Yes, by design. Supabase has two kinds of client key: the publishable key (sb_publishable_...) and the legacy anon key it replaces (a long JWT starting with eyJ). Both are meant to ship inside your web or mobile app. The API keys guide puts it plainly: "Anyone can read it, so it only reaches what Row Level Security allows."

So the key in your JavaScript bundle is not a lock. RLS is. With RLS off, the key opens the whole table. If you want the longer version of that argument, read whether the Supabase anon key is safe to expose.

Why "usually write" and not just read? Postgres checks two things before a request touches a table. Grants decide whether a role may run an operation at all; RLS decides which rows it applies to. According to Securing your API, on existing projects every new table in public is granted select, insert, update and delete for the anon and authenticated roles by default. Supabase says it is changing that default, so a newer project may grant less. Either way, on a table this lint flags, reads are open.

Is RLS enabled by default in Supabase?

Only for tables created in the dashboard. Supabase's docs say tables created through the dashboard have RLS enabled by default, and you have to enable it yourself for tables created in the SQL Editor or any other tool. Postgres itself creates every table with RLS off.

That is how many AI-built apps end up here. AI assistants often create tables by writing SQL migrations, and unless the migration also contains enable row level security, the table is born open.

How to check your own project

Three checks, from quickest to most convincing. Run all three against your own project only.

1. Run the Security Advisor

In the dashboard, open Advisors and then Security Advisor. Any table flagged under lint 0013, Table publicly accessible, has this problem. The same checks run from the CLI with supabase db advisors. See the Advisors docs for the full list of lints.

2. List tables with RLS off

Run this in the SQL Editor. pg_tables has a rowsecurity column that is true when RLS is on.

sql
select schemaname, tablename
from pg_tables
where schemaname = 'public'
  and not rowsecurity
order by tablename;

If you exposed other schemas under the Data API settings, check those too by changing 'public'.

While you are there, find tables that have RLS on but no policies yet. These return nothing to your app, which is safe but probably not what you meant:

sql
select t.tablename
from pg_tables t
left join pg_policies p
  on p.schemaname = t.schemaname
 and p.tablename = t.tablename
where t.schemaname = 'public'
  and t.rowsecurity
  and p.policyname is null;

3. Ask the API what a stranger would see

This is the test that settles it. Use your own project ref and your own publishable key (or the legacy anon key, if that is what your app ships), and a table you own:

bash
curl 'https://YOUR_PROJECT_REF.supabase.co/rest/v1/profiles?select=*&limit=5' \
  -H "apikey: YOUR_PUBLISHABLE_KEY"

If rows come back for a table that holds user data, anyone who opens your site's JavaScript can make the same request. After the fix you should get [] (RLS is on and no policy matches) or a 42501 permission error (the role has no grant on the table).

How to fix Supabase RLS disabled in public

How to enable row level security in Supabase

One statement per table:

sql
alter table public.profiles enable row level security;

To enable it on every table in public that still has it off:

sql
do $$
declare
  t record;
begin
  for t in
    select schemaname, tablename
    from pg_tables
    where schemaname = 'public' and not rowsecurity
  loop
    execute format('alter table %I.%I enable row level security', t.schemaname, t.tablename);
  end loop;
end
$$;

Run the bulk version only if you are ready to add policies straight after, because of what happens next.

Why does my table return nothing after enabling RLS?

Because RLS denies by default. The Postgres docs say: "If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified." The API returns an empty list with no error, which looks like the data vanished.

It didn't. The SQL Editor still shows every row because it runs as the postgres role, which owns your tables and bypasses RLS. Your app's requests run as anon or authenticated, and those now need a policy.

Add the minimal policies

For a table where each row belongs to one user through a user_id column, these four policies follow the shape in Supabase's Row Level Security guide, one per operation:

sql
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can create their own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Users can delete their own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );

create index profiles_user_id_idx on public.profiles using btree (user_id);

The (select auth.uid()) wrapper and the index on user_id are Supabase's performance advice: the wrapper lets Postgres compute the user id once per query instead of once per row. Leave out any operation your app doesn't need; no policy means no access. For teams, public-read tables and the mistakes that quietly undo all this, see Supabase RLS policy examples.

Supabase's guide also recommends taking back the default grants and granting only what each role uses. For a table signed-out visitors never touch:

sql
revoke all on table public.profiles from anon, authenticated;
grant select, insert, update, delete on table public.profiles to authenticated;

Tables that are public on purpose

A pricing table, a list of countries, published blog posts: data everyone may read. Enable RLS anyway. Without it, and with the default grants, "public" also means anyone can rewrite or delete it. Add one read-only policy:

sql
alter table public.plans enable row level security;

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

With no insert, update or delete policy, the API roles cannot write. Your server can still write with the secret key, because the service_role role it maps to bypasses RLS (which is why that key must never reach the browser: see the Supabase service role key). Expect the Security Advisor to list this policy as "Security policy allows unrestricted access". That is correct for this one table, and Supabase's lint notes name public read-only tables as an intended exception.

Put the fix in a migration

A fix typed into the SQL Editor lives only in that database. A preview branch, a second environment, or a supabase db reset rebuilds the schema from your migration files and brings the open table back. Put the change where the table was created:

bash
supabase migration new enable_rls

That creates a timestamped .sql file in supabase/migrations/. Paste the alter table statements and policies into it, then apply it to your hosted project:

bash
supabase db push

If you already fixed things in the dashboard, supabase db diff -f enable_rls writes those changes into a migration for you. The migrations guide covers both. From now on, keep a simple rule, and give it to your AI assistant too: every create table in public is followed, in the same file, by alter table ... enable row level security and its policies.

How to enable RLS on all new tables automatically

Supabase documents an event trigger that turns RLS on for every table created in public after you install it:

sql
CREATE OR REPLACE FUNCTION rls_auto_enable()
RETURNS EVENT_TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE
  cmd record;
BEGIN
  FOR cmd IN
    SELECT *
    FROM pg_event_trigger_ddl_commands()
    WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
      AND object_type IN ('table','partitioned table')
  LOOP
     IF cmd.schema_name IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN
      BEGIN
        EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity);
        RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity;
      EXCEPTION
        WHEN OTHERS THEN
          RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity;
          RAISE;
      END;
     ELSE
        RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name;
     END IF;
  END LOOP;
END;
$$;

DROP EVENT TRIGGER IF EXISTS ensure_rls;
CREATE EVENT TRIGGER ensure_rls
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
EXECUTE FUNCTION rls_auto_enable();

Three things to know. Only the postgres user can create event triggers. It covers tables created after you install it, so existing tables still need the fix above. And it enables RLS without adding policies, so a new table returns nothing until you write them. That is the safe way to fail.

Fix checklist

  1. Open the Security Advisor, or run the pg_tables query, and list every flagged table.
  2. For each table, decide who should reach it: only the row's owner, a team, everyone (read-only), or only your server.
  3. Run alter table ... enable row level security on each.
  4. Add one policy per operation the app needs. Add nothing for operations it doesn't.
  5. Optionally revoke the default grants and grant back only what each role uses.
  6. Test with the curl request above as a signed-out visitor, then sign in and check the app still works.
  7. Put the same SQL in a migration file and push it.
  8. Install the event trigger if you want new tables to start with RLS on.
  9. Run the Security Advisor again until the table no longer appears.

A Supabase RLS checker for your migrations

The Security Advisor checks the database as it is now. Your repository decides what the next environment gets. A rough check over the migration files is to list the tables they create and the tables they enable RLS on, then compare:

bash
grep -rhoiE "create table( if not exists)? [a-z0-9_.\"]+" supabase/migrations | sort -u
grep -rhoiE "alter table [a-z0-9_.\"]+ enable row level security" supabase/migrations | sort -u

Any table in the first list and missing from the second needs a look. It is crude: it misses alter table only and tables renamed later.

If you'd rather not compare lists across 40 migration files, a free Tuvio scan needs no account and flags tables with RLS disabled in the repository's .sql migrations, along with policies that let anyone read every row. It reads the files in your repository, not your live database, so a table you created in the dashboard without a migration won't show up there. The Security Advisor covers that side. For everything else to look at before launch, see the vibe coding security checklist.