How to check your Supabase row level security like an attacker would
Supabase hands every visitor of your app a key to your database. That's by design: the anon (now "publishable") key sits in your front-end bundle, and anyone can copy it from the browser's developer tools in a few seconds. What stands between that key and your users' data is row level security, a Postgres feature that decides, row by row, who may read or change what.
When RLS is missing or written too loosely, the public key is enough to dump a table. This guide walks through the case that made this concrete in 2025, then the four checks I run on any Supabase project before it takes real users.
What CVE-2025-48757 showed
In March 2025, Matt Palmer, an engineer at Replit, looked at apps listed in Lovable's public showcase. He only visited their homepages, without logging in or forcing anything. Out of 1,645 apps, 170 projects exposed data through 303 endpoints, roughly 10.3% of the sample.
The exposed tables held personal data such as names and email addresses, third-party API keys (Google Maps, Gemini, eBay), transactions and subscriptions. Access wasn't limited to reading: he was able to set a record's payment_status to "paid" without any payment going through Stripe.
The timeline in his report is worth knowing:
- March 20–21: first vulnerable app found, wider scan, email to Lovable.
- April 14: another engineer, Danial Asaria, publicly showed data being extracted in 47 minutes.
- April 24: Lovable 2.0 shipped a "security scan". According to the report, it checked that a policy existed, not that the policy actually protected anything.
- May 29: public disclosure.
The NVD record rates the issue 9.3, critical. Lovable disputes the CVE, on the grounds that each app's data security is its creator's responsibility. Whatever you think of that argument, the practical lesson is the same for any Supabase project, AI-built or not: the public key was never the bug. The policies were.
Step 1: make sure RLS is on for every table
Tables you create in the Supabase dashboard get RLS enabled by default. Tables created with SQL, through migrations, an ORM or an AI tool writing raw SQL, do not. That gap is the most common way a table ends up wide open.
List the tables in your exposed schema that still have RLS off:
select relname as table_name
from pg_class
join pg_namespace on pg_namespace.oid = pg_class.relnamespace
where nspname = 'public'
and relkind = 'r'
and not relrowsecurity;
Any row in the result needs fixing:
alter table public.invoices enable row level security;
Enabling RLS without writing a policy is safe: Postgres then denies every request from the API roles, so the table returns nothing until you decide who can see what. Failing closed is exactly what you want while you write the policies.
Step 2: replace blanket policies with owner checks
A policy that reads using (true) lets everyone read every row. Its slightly better-looking cousin, to authenticated using (true), lets anyone who signs up read every other account's data, and signing up is free.
A policy should tie each row to the person asking. For a table where every row has a user_id:
create policy "Owners read their invoices"
on public.invoices for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Owners update their invoices"
on public.invoices for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
Two details matter here. The with check clause stops someone from editing a row and reassigning it to another user. And wrapping auth.uid() in a select is the form Supabase's documentation recommends, because Postgres can then evaluate it once per query instead of once per row.
Some columns should never be writable from the browser at all: payment status, plan, role, credit balance. Keep them out of reach of the authenticated role, for example by revoking update on those columns, and change them only from server-side code that has verified a Stripe webhook or an admin action.
Step 3: query your own project as a stranger
Reading policies is not the same as testing them. Do what Palmer did, on your own project only: call the REST API with nothing but the public key.
curl 'https://YOUR_PROJECT.supabase.co/rest/v1/invoices?select=*' \
-H 'apikey: YOUR_PUBLISHABLE_KEY'
An empty array or a permission error is the result you want. If rows come back, every visitor to your site can get them too. Repeat the call for each table, and try a write as well (a PATCH on one of your own test rows) to confirm that anonymous updates fail.
Then test the case most audits skip: two real accounts. Sign in as user A and create some data. Sign in as user B in another browser profile, and from the developer console try to fetch A's rows by ID, or replay one of A's requests with B's session. If B sees anything that belongs to A, a policy is checking that someone is logged in rather than who.
Step 4: rotate whatever was exposed
If a table was open at any point while the app was online, treat its contents as copied. Any API key stored in that table is compromised until it has been rotated with the provider that issued it, whether that's Google, OpenAI, Stripe or anyone else. Tell affected users if personal data was involved; depending on where they live, you may also have legal deadlines to meet.
Also confirm that your secret key, which starts with sb_secret_ (the legacy name was service_role), appears nowhere in front-end code. Supabase's documentation is explicit that this key bypasses every row level security policy. Search your built JavaScript for it, not just your source files, because environment variables can leak into bundles at build time. On older projects the keys are JWTs starting with eyJ, and the role is encoded inside: searching for the word service_role finds nothing even when that key is exposed. Decode the middle part of each eyJ… string with atob() in the browser console; "role":"anon" is expected, "role":"service_role" is not.
Let Supabase's advisor do a second pass
In the dashboard, the Security Advisor flags tables without RLS and several other risky settings. It's a useful safety net after the manual checks above, not a replacement for them: like the Lovable scan in the report, a linter can tell you that a policy exists, not whether it encodes the right rule for your app.
One platform change is also worth tracking. New projects have stopped exposing newly created tables to the Data API without an explicit grant since May 30, 2026, and existing projects follow on October 30, 2026. It reduces accidental exposure, but once a table is granted, its rows are still filtered only by your RLS policies.
FAQ
Is the Supabase anon key safe to put in my front end?
Yes. The anon or publishable key is designed to be public and identifies your project, not a privileged user. Your data is protected by row level security policies, so the key is only safe when every exposed table has correct policies.
What is the difference between the anon key and the secret key?
The anon or publishable key runs requests with the limited anon or authenticated role, subject to your policies. The secret key, formerly called service_role, bypasses row level security entirely and must only ever live on a server.
How often should I re-run these checks?
After every migration that creates a table or changes a policy, and before each launch. Tables added in a hurry, especially through raw SQL or AI-generated migrations, are where RLS most often gets forgotten.