Securealy
Guide · Supabase · Row level security

Supabase RLS disabled: what does it expose?

Every row of the table, to anyone. The publishable key in your page reaches the database API directly, and a table without row level security answers it with all its rows, for reading and for writing. No login, no API of yours in the path. This one setting is behind most reported data exposures in AI-built apps.

What exactly can a stranger do?

With your publishable key and a table that has RLS off, a stranger can list the table through the REST API, read every row, insert rows, update rows and delete them. The key is in your page source, so finding it takes a view-source and a search for supabase.co.

Supabase's table documentation states the consequence without softening it: until you enable row level security and write a policy, anyone holding your project's publishable key can read and write every row in it. The key is not a secret and was never meant to be one; it identifies your project to the API and is designed to ship in the browser. The policy is the whole defence.

The data behind the headlines is what sat in those tables. Moltbook's exposed database held 1.5 million API tokens, 35,000 email addresses and private messages, readable and writable with the key in its client JavaScript. The February 2026 education app held 18,697 user records. The first Lovable showcase scan turned up personal debt amounts, home addresses and third-party API keys from 170 of 1,645 apps. Whatever your users typed in is what the table exposes: profile fields, uploaded document paths, order history, the tokens your app stored for them.

Writing is the part people forget. An open table accepts inserts and updates from the same key, so a stranger can change prices in a products table, flip a subscription flag, or fill a table with junk until your bill moves. The March 2025 app that was hacked within days of going viral reported exactly that: strangers creating rows in the database and bypassing the paywall.

Why is RLS off on tables my AI created?

Because the default depends on how the table was made. Tables created in the Supabase dashboard get row level security on automatically. Tables created with SQL, through the API or by a coding agent do not, unless the statement enables it. Agents create tables the second way, so their tables start open.

The documentation says it in two sentences: 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. UpGuard's September 2026 study of 16,326 readable databases put the gap in one line: tables created programmatically through the API, which is how coding agents interact with Supabase, do not enable RLS by default. More than half of those databases held indicators of personal data.

The second way a table ends up open looks closed. RLS is on, and a policy for the anon role says USING (true), which lets everyone through. Agents write that policy when a page errors on an empty result and the fastest fix is to allow everything. The dashboard shows a green RLS badge. The table is public. rls-policies flags both states: RLS off, and RLS on with a permissive anon policy.

The numbers across every scan say the same. Reeve's August 2026 sweep of 30,998 live apps found that 2,096 of 3,680 checkable Supabase apps, 57%, allowed unauthenticated table reads. The January 2026 SupaExplorer report found 11.04% of 20,052 scanned URLs shipping Supabase credentials, some of them keys that bypass RLS entirely. The dates in the ledger run from March 2025 to September 2026 with no sign of the rate falling.

How do I tell if my tables are exposed?

Ask the database API with your own key. A request to the REST root with the key from your page either lists your tables or does not; a select on each table either returns a row or returns nothing. Both can be done from outside with no access to your project. We run both.

From a terminal, with the URL and key copied from your page source:

curl -s "SUPABASE_URL/rest/v1/" -H "apikey: PUBLISHABLE_KEY" | head -c 400
curl -s "SUPABASE_URL/rest/v1/TABLE_NAME?select=*&limit=1" -H "apikey: PUBLISHABLE_KEY"

A table list from the first command is the map an attacker reads before the first row is pulled; it is what supabase-rest-exposed tests on any URL, reading metadata only. A row from the second is the exposure; that is supabase-anon-row-read, which runs after you verify ownership and asks each table found in your bundle for a single row, storing nothing. A 401, an empty array or a permission error passes. A row fails and the check is a launch blocker. A table that is public on purpose still fails here, so the report names it and you confirm it is intended.

From inside the project, the catalogue is the source of truth:

select tablename from pg_tables where schemaname = 'public' and not rowsecurity;
select tablename, policyname, roles, qual from pg_policies where qual = 'true';

Expect an empty result from both. spatial_ref_sys in the first list is the one tolerated exception: PostGIS owns it and you cannot alter it.

What is the fix?

Turn RLS on for every table in the public schema, revoke the direct grants the public roles hold, and change the default privileges so the next table starts closed. Then write the narrowest policy for each table a page must read, with the reason in the policy name. Server code reads with the secret key.

The baseline is one re-runnable script, and the Fix Kit ships it as rls-baseline.sql in Module 03. In outline:

alter table public.TABLE_NAME enable row level security;   -- every table
revoke all on all tables in schema public from anon, authenticated;
revoke all on all sequences in schema public from anon, authenticated;
alter default privileges in schema public revoke all on tables from anon, authenticated;
notify pgrst, 'reload schema';

Stop before running it on a live project. Any page that reads a table from the browser with the publishable key goes blank until a policy exists for that table. For most apps the right number of browser-readable tables is zero: the server reads with the secret key after it has checked who is asking. For the few tables a page must read directly, both lines, every time, because a grant without a policy opens nothing and a policy without a grant does nothing:

grant select on public.TABLE_NAME to authenticated;
create policy "TABLE_NAME: read own rows" on public.TABLE_NAME
  for select to authenticated using (user_id = auth.uid());

A table that is public on purpose gets a policy named so, "TABLE_NAME: public read, intended", and a line in a file that lists every open table. Do not flip a security definer view to invoker without a reason; a view that serves a public page from locked tables goes blank the moment it runs as the caller. Then prove it: the two catalogue queries above return nothing, and the anonymous select returns nothing. The full runbook, with the rotation steps if a secret key was also exposed, is in Fix Kit Module 03; the check page carries the free version.

Grade it

Find out which of your tables answer

The grade reads the key from your page and asks the database API what it will list. Verify ownership and it asks each table for a row. Nothing is stored but the verdict.

Checks this guide refers to: supabase-rest-exposed, supabase-anon-row-read, rls-policies, storage-bucket-listing, secrets-in-bundle.