Supabase RLS checker: test your Row Level Security
If your app talks to Supabase from the browser, Row Level Security is the lock on your data. Here's how to check it yourself, what "good" looks like, and how to fix it.
Why RLS matters so much in AI-built apps
Lovable, Bolt, v0 and other AI builders often wire your frontend straight to Supabase. The browser gets your project URL and a public key (the legacy anon JWT, or the newer sb_publishable_ key). Supabase's own API keys guide is clear that the public key is safe to expose only because it reaches nothing beyond what Row Level Security allows, and it tells you to enable RLS on every table.
So the question isn't "is my key hidden?" (it can't be) but "what can someone do with it?" Without RLS, the answer can be: read and edit every row in the table. That's the pattern behind CVE-2025-48757, where researchers reported missing or weak RLS on Lovable-built apps exposing user records, and in one case allowing a forged "paid" record.
Step 1: find tables with RLS turned off
In the Supabase SQL editor:
select n.nspname as schema, c.relname as table_name, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname = 'public'
order by c.relrowsecurity, c.relname;
Tables with rls_enabled = false sit at the top. In Supabase, tables in the public schema are reachable through the auto-generated API by default, so each of these deserves attention right away. The dashboard's Security Advisor gives you a second opinion and flags the same issue.
Step 2: list every policy and read it like an attacker
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
Red flags to look for:
qualorwith_checkistrueon a table with personal data. That means "everyone", not "the owner".- Roles include
anon(orpublic) for select, update or delete on private data. - An insert or update policy with no
with_check. Users may be able to write rows that belong to someone else, or set fields likeis_adminorplan. - RLS enabled but no policies at all. That blocks everything through the API. It's safe, but it often makes an AI assistant "fix" the resulting error by adding a wide-open policy. Watch for that.
Step 3: test from the outside
SQL tells you what the rules say. A real request tells you what they do. Using your own project URL and public key (both are visible in your app's network requests):
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5" \
-H "apikey: YOUR_PUBLIC_KEY"
This is a logged-out visitor's view. For a private table you want [] or a permission error. If you see real rows, anyone can. Repeat for each table, then sign in as a test user and confirm you only see that user's rows.
Also try a write as a logged-out visitor (on a test row you own):
curl -X PATCH "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?id=eq.SOME_ID" \
-H "apikey: YOUR_PUBLIC_KEY" -H "Content-Type: application/json" \
-d '{"display_name":"rls-test"}'
If that succeeds without a login, your update policy needs work.
Step 4: fix it
The baseline for a table where each row belongs to one user, following the pattern in Supabase's RLS docs:
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 insert 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);
create policy "owners delete their notes" on public.notes
for delete to authenticated
using ((select auth.uid()) = user_id);
Naming the role with to authenticated keeps the policy from applying to logged-out visitors at all, and wrapping auth.uid() in a select is the form Supabase's docs recommend.
Or let your AI do it, with guardrails:
Enable Row Level Security on every table in the public schema. For each table, tell me who should be able to select, insert, update and delete, then write one policy per operation using "to authenticated" and "(select auth.uid()) = user_id" (or the right ownership column). Every insert and update policy must have a WITH CHECK clause. Never use "using (true)" on tables containing user data. Show me the final SQL before applying it.
Users must not be able to change their own role, plan, credits, balance or payment status. Remove those columns from any update policy the user can use, or move them to a separate table that only the service role can write, and update them from a server-side function.
Write pgTAP tests (supabase test db) that check: an anonymous user cannot read or write any private table, user A cannot read or change user B's rows, and a user cannot change their own role or plan.
Step 5: make it stick
- Every time your AI adds a table, rerun Step 1. New tables are where gaps show up.
- Be suspicious of any change that "fixes" a permission error by widening a policy.
- Never "solve" RLS by switching the frontend to the service_role key. That key skips RLS completely and would be public. See service_role key exposed.
Where ShipWatch fits
To be upfront: the free ShipWatch scan is passive and does not query your database, so it can't grade your RLS policies. Use the steps above for that. What it does catch are the mistakes around RLS: a service_role or sb_secret_ key shipped in your JavaScript (which makes RLS irrelevant), other leaked API keys, an exposed .env, and missing security headers. Run it after every big change.