Short answer
Check four things: every table in the public schema has RLS turned on, no rule on private data
says using (true), no view or database function hands out rows your rules protect, and users
can't edit columns like role or plan. Then test it the way an attacker would: as a
stranger with your public key, and as a second user.
What RLS does
A Supabase app's website talks straight to the database through Supabase's API. To do that, it ships a public key (the anon key, called the publishable key in newer projects) inside your site's JavaScript. Anyone can open the browser's developer tools and copy it. That's by design: the key is meant to be public.
What keeps your data private is Row Level Security (RLS), a Postgres feature. When RLS is on
for a table, every request is filtered through rules called policies. A policy says, for
example, "a logged-in user can read a note only if user_id is their own id". When RLS is off, the
public key can read, change and delete every row.
Why "RLS enabled" isn't enough
Apps built with AI tools like Lovable, Bolt or Replit usually do switch RLS on. The holes tend to be one step further in:
-
Rules that let everyone in. A policy with
using (true)passes every request. AI tools often write one to make an error go away. -
Rules for "logged in", not "the owner". A policy for the
authenticatedrole withusing (true)lets any user who signs up read every other user's rows. Sign-up is usually open to anyone. -
Columns a user shouldn't touch. RLS works on whole rows. If users may update their own
profile row, they can also change its
role,is_adminorplancolumn unless something else stops them. -
Views and functions that skip the rules. A view runs with its creator's rights unless it
is set to
security_invoker, and asecurity definerfunction runs with full rights. Both can hand out rows your policies protect.
Supabase's free Security Advisor (in your project dashboard) catches several of these from your settings, and Supabase has been adding tools to test rules as a given user. A settings review alone won't tell you what a stranger or a second user can actually get, so a rule that's switched on but too loose can still look fine.
How to check, step by step
Run these in your project's SQL editor. In the Supabase dashboard it's SQL Editor; on Lovable Cloud it's under More, then Cloud, then SQL editor. They only read your database's structure, not your data.
1. Find tables with RLS off
Any table listed here as false is open to anyone holding your public key.
select c.relname as table_name, c.relrowsecurity as rls_on
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('r', 'p')
order by rls_on, table_name;
2. Find rules that let everyone in
This lists every policy whose condition is simply true. Each one needs a decision: fine for a
public product list, a leak for messages, orders or profiles.
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true');
3. Find views that ignore your rules
Views listed here run with their owner's rights, so RLS on the tables underneath doesn't apply to them.
select c.relname as view_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'v'
and not coalesce(c.reloptions && array['security_invoker=true', 'security_invoker=on'], false);
4. Find full-rights functions anyone can call
A security definer function in public can be called through the API. If it returns
rows, check that it only returns what the caller is allowed to see.
select p.proname as function_name
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef
and has_function_privilege('anon', p.oid, 'execute');
5. Try it as a stranger
Settings tell you what should happen. A real request tells you what does. With your project URL and public key (both in your site's code anyway), ask for a table that should be private. Only do this on a project you own or manage.
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5" \
-H "apikey: YOUR_PUBLIC_KEY"
An empty list [] is what you want for private data. Rows coming back means anyone on the internet
can get them too.
6. Try it as another user
The stranger test misses a common leak: one signed-up user reading another user's rows. Sign up two test accounts, log in as the second one, and try to load the first one's data. You can also test from the SQL editor.
Newer Supabase dashboards let you pick a role next to the Run button in the SQL editor: choose authenticated and a user, then query the table. Without that option, run this in the SQL editor:
set role authenticated;
select set_config('request.jwt.claims', '{"sub": "SECOND-USER-UUID", "role": "authenticated"}', false);
select * from public.notes; -- should show only the second user's rows
Afterwards, run reset role; in the same SQL editor tab, or just reload the editor.
Rather not run all of this by hand? The free Lockstamp check finds these patterns and tries the attacks as a stranger and as a second user, on a private copy in your browser.
Run the free checkHow to fix what you find
The usual fix is to turn RLS on and give each private table owner-only rules. This example is for a
notes table with a user_id column. Change the table and column names to match yours,
and read it before you run it.
alter table public.notes enable row level security;
-- Drop any rule that lets everyone in, for example:
drop policy if exists "Enable read access for all users" on public.notes;
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 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 delete their notes" on public.notes
for delete to authenticated using ((select auth.uid()) = user_id);
For the other holes from the checks above:
Make a view follow the caller's rules
alter view public.my_view set (security_invoker = true);
Stop strangers calling a full-rights function
revoke execute on function public.get_all_emails() from anon, authenticated, public;
If logged-in users need that function, keep their access and change it so it only returns the caller's own rows (for example where user_id = auth.uid()).
Let users edit only safe columns of their profile
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;
List every column your profile form saves, or those edits will start failing. If users create their own profile row, they could set role there too, so give it a default and limit the insert grant the same way.
After any change, log in as a normal user and click through your app's main features. A page that goes empty
usually means a missing select rule. Our
RLS not working guide covers those cases.
What no checker can tell you
A checker can show that a stranger can read a table. It can't know whether you meant that table to be public. Product listings and public reviews often should be readable by everyone; messages and orders should not. In rebuilds from public migration files of 27 projects, our attacks worked in 14, and most of those results still needed a person to decide whether the data was meant to be public.
Database rules are also only one part of an app. Keys pasted into front-end code, storage buckets, server functions and login settings need their own review, and no tool can promise an app has no other weaknesses.