Supabase row level security: is your data really private?
Every Supabase app ships its anon key to the browser. That is by design: the key only identifies the project, and row level security (RLS) policies decide what each user can read and write. If a table has RLS off, or a policy that allows everything, anyone with the key can read the whole table.
- Updated
- October 5, 2026
- Written by
- Nitya Hoyos
- Checks
- 7, each with a fix
- You need
- A browser and a terminal
(01)Why it matters
Supabase exposes your tables through an API that anyone can call with the project URL and anon key, both of which are visible in your app’s JavaScript. The database decides what each request may see by checking RLS policies against the user’s login. With RLS enabled and no policies, a table returns nothing. With RLS disabled, it returns everything.
Apps built quickly, by hand or with AI tools, tend to fail in the same few ways: a table created in SQL without RLS turned on, a policy written as using (true) to make an error go away, an update policy without a with check clause so users can reassign rows to someone else, or a view that reads a protected table with the view owner’s rights. Each of these passes every test where you are logged in as yourself and only shows up when someone else asks for your data.
The checks below need only the project URL and anon key from your app and a terminal. Run them against a staging copy if you have one.
(02)The checks
Run these before launch
- 01
Every table in the public schema has RLS enabled
How to check
Open the Security Advisor in the Supabase dashboard, or run: select tablename, rowsecurity from pg_tables where schemaname = 'public'; Any row with rowsecurity false is readable and writable through the API.
Fix
alter table public.<table> enable row level security; then add policies for exactly what each role needs.
- 02
Logged-out requests return nothing private
How to check
curl "https://<project>.supabase.co/rest/v1/<table>?select=*" -H "apikey: <anon key>". If private rows come back, anyone on the internet can read them.
Fix
Write policies with to authenticated and a condition on the user, never using (true) on private data.
- 03
Users can only read their own rows
How to check
Sign in as a second test user and request the first user’s rows by ID. A policy that checks only that the user is logged in lets every user read every row.
Fix
Use a condition such as (select auth.uid()) = user_id in the using clause.
- 04
Update and insert policies have with check
How to check
As a test user, try updating one of your rows to set user_id to another user’s ID. If it succeeds, users can plant rows in other accounts.
Fix
Add with check ((select auth.uid()) = user_id) to insert and update policies.
- 05
Views and functions don’t bypass RLS
How to check
List views in the public schema. Views run with their owner’s rights by default, so a view over a protected table can expose it. Check functions marked security definer that are callable through /rest/v1/rpc.
Fix
Create views with (security_invoker = true) on Postgres 15 or later, or move them out of the exposed schema; keep security definer functions out of it too.
- 06
Storage buckets match what they hold
How to check
Check which buckets are public. Files in a public bucket can be fetched by anyone with the URL, whatever your app’s login says.
Fix
Make private buckets private and add policies on storage.objects that check the user.
- 07
The service role key is not in the browser
How to check
Search your built JavaScript and environment files for service_role or the key itself. Environment variables prefixed with VITE_ or NEXT_PUBLIC_ are bundled into the browser code.
Fix
Use the service role key only in server code or edge functions, and rotate it if it was ever shipped.
(03)Example
alter table public.notes enable row level security;
create policy "Owners read their notes" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "Owners add their notes" on public.notes
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "Owners update their notes" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);(04)Beyond the checklist
When to get help
If several tables are shared between users (teams, organizations, invites, admin roles), policies get harder to reason about and easier to get subtly wrong. That is where a second pair of eyes pays for itself.
(05)Questions
Is it safe that my Supabase anon key is public?
Yes, as long as RLS is enabled on every exposed table and the policies are right. The anon key is designed to be public; RLS is what protects the data. The service role key bypasses RLS and must never be public.
How do I know if RLS is enabled on my Supabase tables?
The Security Advisor in the Supabase dashboard flags tables without RLS. You can also query pg_tables for the rowsecurity column in the public schema.
Why does my app break when I enable RLS?
With RLS on and no policies, every query returns nothing. Add a policy for each action the app needs (select, insert, update, delete), limited to the rows the user should reach, rather than turning RLS back off.
Want someone else to run these checks?
The AI-Built App Audit covers everything on this page and more, in 5 business days, with the file and line for each issue. $395 flat.