Guides / Supabase RLS check

How to check your Supabase RLS before launch

If your app runs on Supabase, row-level security is the only thing standing between your users’ data and anyone who opens browser dev tools. Here’s how to check it yourself in under an hour, no security background needed.

Get your app checked by hand

Why this matters more than anything else

Your Supabase project URL and publishable key (the older name is the anon key) are in your frontend code. That’s by design. Anyone can open your site, look at the network requests and copy them. The only thing that decides what those keys can read and change is row-level security (RLS).

Supabase’s own database linter treats a table in the public schema with RLS turned off as an error: anyone with your project URL can read, insert, update and delete its rows through the API. This is not theoretical. In 2025, CVE-2025-48757 was published describing insufficient RLS policies in apps generated with Lovable that let unauthenticated attackers read or write database tables. Lovable disputes the CVE, saying customers are responsible for protecting their own data. Either way, the takeaway for founders is the same: the AI builds the app, but checking who can see what is on you.

Step 1: Run Supabase’s Security Advisor

In your Supabase dashboard, open Advisors → Security Advisor. Look for any warning titled RLS Disabled in Public. Each one names a table that anyone can read and write right now.

The fix for each table is one line of SQL in the SQL Editor:

alter table public.your_table enable row level security;

Expect parts of your app to “break” after this. With RLS on and no policies, the publishable key can’t read anything from that table. That’s the safe default. Your next job is to add policies that let each user reach only their own rows.

Step 2: Read your policies, not just the switch

RLS being on doesn’t mean it’s doing anything useful. In the dashboard, open Authentication → Policies and read each one. These are the mistakes that show up again and again in AI-generated projects:

  • using (true) on a private table. This means “everyone can read every row.” It’s fine for a public list of blog posts, and a data leak for profiles, messages, orders or documents.
  • Policies granted to anon or public when only signed-in users should have access.
  • Insert policies without a with check. A user can create rows that claim to belong to someone else.
  • Update policies with with check (true). A user can edit their own row and change its user_id to someone else’s. (If you leave with check out entirely, Postgres reuses the using rule for the edited row, which is safer, but writing it explicitly makes the rule clear.)

For a table where each row belongs to one user, a sound set of policies looks like this. Replace profiles and user_id with your table and column:

create policy "Users read their own rows"
on public.profiles for select to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users insert their own rows"
on public.profiles for insert to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users update their own rows"
on public.profiles for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Users delete their own rows"
on public.profiles for delete to authenticated
using ( (select auth.uid()) = user_id );

Wrapping auth.uid() in (select …) is Supabase’s recommended pattern: it gives the same result but lets Postgres evaluate it once per query instead of once per row.

Want a faster first pass? Paste your policies into the free Supabase RLS policy checker. It flags these patterns right in your browser, and nothing you paste is sent anywhere.

Step 3: Test as a logged-out stranger

Reading policies only tells you what you meant to do. Testing tells you what actually happens. Run this from a terminal against your own project, using the URL and publishable key from your app’s settings:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer YOUR_PUBLISHABLE_KEY"

For any table that holds private data, the answer should be an empty list: []. If you see real rows, a stranger can see them too. Repeat for every table.

Step 4: Test as a second user

The most common real-world leak isn’t strangers. It’s one signed-in user reading another user’s data. To check:

  1. Create two test accounts in your app, User A and User B, and add some data as each.
  2. Sign in as User A. In browser dev tools, open Application → Local Storage, find the key that looks like sb-…-auth-token, and copy the access_token value.
  3. Ask for User B’s rows using User A’s token:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?user_id=eq.USER_B_ID&select=*" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer USER_A_ACCESS_TOKEN"

You want []. Then try changing User B’s data with User A’s token. This should report no rows changed:

curl -X PATCH "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?user_id=eq.USER_B_ID" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer USER_A_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"display_name":"changed by A"}'

An empty [] response means the update was blocked. If you get User B’s row back, User A just edited it.

Step 5: Check the places RLS doesn’t cover

  • Your secret key. Secret keys (sb_secret_…, or the older service_role key) bypass every RLS policy. They belong only on a server or in an Edge Function, never in frontend code or a public repo. Supabase blocks secret keys used from a browser, but a leaked key still works from anywhere else, so rotate any key that was ever exposed.
  • Views. Views bypass RLS by default. On Postgres 15 and later, create them with with (security_invoker = true) so they respect the underlying tables’ policies.
  • Database functions. Functions marked security definer run with their creator’s privileges and can skip RLS. If your app calls them with .rpc(), make sure each one checks who is calling.
  • Storage buckets. Files in a public bucket can be downloaded by anyone with the URL. Put private uploads in a private bucket and add policies on storage.objects so users can reach only their own files.

A fix prompt you can paste into your AI tool

If you built with Lovable, Bolt, Cursor or Replit, you can hand the fix back to the tool. Review the SQL it writes before you run it, then repeat Steps 3 and 4 to confirm it worked.

Audit every table in my Supabase public schema. For each table:
1. Enable row level security if it is off.
2. Remove any policy that uses "using (true)" on tables containing
   user data, and any policy granted to anon that should require login.
3. Add select, insert, update and delete policies so signed-in users can
   only access rows where user_id = (select auth.uid()). Insert and
   update policies must include a WITH CHECK clause.
4. List any views, security definer functions and storage buckets that
   could expose user data, and explain how each one is protected.
Show me the SQL before applying it.

The short version

  • Security Advisor shows no RLS Disabled in Public warnings.
  • No private table has a using (true) policy.
  • Insert policies have a with check, and no policy uses with check (true).
  • A logged-out request returns [] for every private table.
  • User A can’t read or change User B’s rows.
  • No secret or service_role key anywhere in frontend code.
  • Views, security definer functions and storage buckets reviewed.

Want a second pair of eyes? The Launch Check covers all of this plus exposed keys, unprotected routes and your AI features, with fixes you can paste into your coding tool.

Want a second pair of eyes?

Get your app checked before launch.

The Launch Check covers your database rules, exposed keys, unprotected routes and AI features by hand, with plain-English findings, fixes you can paste into your AI coding tool, and a recheck. $400, fixed price.