Skip to content
ShipSecurelySecurity reference
Menu
Your tool here — sponsor ShipSecurely

Issues

Supabase tables without row level security

Tables in the public schema are reachable through the Supabase API. With row level security off, the publishable key is enough to read and change every row.

Share link · Share on X

Last updated

criticalDatabase

Shows up in: Supabase, Postgres, Next.js

Markdown version

What it is

A Supabase project exposes tables in the public schema through its Data API. The publishable key (and the older anon key) is meant to ship in the browser. It is not a password. Postgres row level security is what decides which rows that key can touch.

Supabase's database linter states the failure mode directly: if row level security is not enabled on a public table, anyone with the project URL can create, read, update, and delete rows. The Security Advisor flags this as rls_disabled_in_public.

Turning RLS on with no policies blocks the anon and authenticated roles. That is safer than leaving RLS off, and it will make the app look empty until you add the policy you actually want. A policy of using (true) puts you back where you started.

The secret key (sb_secret_..., and the legacy service_role key) bypasses RLS. A tight policy does not protect queries made with that key. See secret keys in the frontend.

How to check in 5 minutes

  1. In the Supabase dashboard, open the Security Advisor, or run supabase db advisors from the CLI. Look for RLS disabled on tables in public.

  2. In the Table Editor or SQL editor, list tables in public and check relrowsecurity. A table the API can see with RLS off is the bug.

  3. In a private browser window, call the Data API with only the publishable key and no user session. If you can select another person's rows, RLS is not doing the job. Do this on your own project, not someone else's.

  4. Read every policy. using (true) or a policy that never compares auth.uid() to an owner column means any signed-in user, and sometimes anyone, can read the table.

How to fix

Supabase

Enable RLS, then add a policy that matches the real rule, usually "a signed-in user may access their own rows."

alter table public.notes enable row level security;

create policy "notes_select_own"
on public.notes
for select
to authenticated
using (auth.uid() = user_id);

create policy "notes_insert_own"
on public.notes
for insert
to authenticated
with check (auth.uid() = user_id);

create policy "notes_update_own"
on public.notes
for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

create policy "notes_delete_own"
on public.notes
for delete
to authenticated
using (auth.uid() = user_id);

Write separate policies for select, insert, update, and delete. An insert policy needs with check, not only using. Afterward, repeat the anonymous API call and confirm it returns nothing it should not.

Do not store a role or plan on auth.users raw user metadata and trust it inside a policy. Supabase documents that end users can change user_metadata through the client libraries. Put privileged flags in a table the user cannot update.

Views and materialized views need extra care. A view can bypass the underlying table's RLS unless it is created with security_invoker = on. Materialized views cannot use RLS. Do not expose those to the anon or authenticated roles if they contain private rows.

Next.js

If a Server Component or Route Handler uses the secret key, RLS does not run. Filter by the signed-in user's id in that query, or stop using the secret key for ordinary user requests and use the publishable key plus the user's session so RLS applies.

Firebase

Firestore does not have Postgres RLS. The same class of bug is an open rules file. Use Firebase rules left open.

Vercel

Vercel hosts the app. It does not enable RLS on your Supabase project. The fix is in Supabase, then a redeploy only if the app was depending on the open table.

Copy-paste prompt

Paste this into Cursor, Claude Code, or whichever agent is editing the repo. Read the diff before you merge it.

You are fixing database access in this repository. Do not add features.

The Supabase Data API exposes tables in the public schema. Row level security must be enabled on every table that should not be world-readable, and policies must match the real rule (usually auth.uid() = user_id). A policy of using (true) is not a fix.

Do this:
1. Find every table the app reads or writes, including ones created in supabase/migrations or SQL strings.
2. Enable RLS on each private table.
3. Add separate select, insert, update, and delete policies. Insert and update need with check.
4. Do not use user_metadata for authorization. Users can edit it.
5. If any server code uses the secret key or service_role, list those call sites. Do not leave a user-facing request on that key unless the code checks the session and filters by that user.
6. Do not weaken a policy to make a failing query pass. If you cannot tell who should see a row, stop and ask.

Show the SQL and the files you changed. Then tell me how to confirm an anonymous request cannot read another user's row.

Questions

Is the publishable key supposed to be in the browser?

Yes. Supabase's API key docs say the publishable key is safe to ship because anyone can read it, and it only reaches what row level security allows. The secret key is the one that must stay on the server. A publishable key in the client with RLS off is the bug. A publishable key in the client with RLS on is normal.

What if enabling RLS breaks the app?

That usually means the app was reading rows the policies will now hide. Add the narrow policy the screen needs, or move that one query to a server path that checks the session. Do not disable RLS to get the data back.

Does RLS on its own stop a secret key?

No. Supabase documents that secret keys use the service_role Postgres role, which has BYPASSRLS. Policies never apply to that key.

Sources

Related