ShipWatchFree scan

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:

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

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.

Scan your app free in 30 seconds

Paste your live URL. ShipWatch checks your page and JavaScript for leaked keys (including Supabase service_role), exposed .env and .git, missing security headers and HTTPS redirects, and gives you a plain-English report with fix prompts. Passive and read-only, no signup.

Run the free scan