What actually went wrong
Supabase exposes your Postgres tables through an auto-generated REST API. Your frontend talks to that API using the anon key (also called the publishable key). That key is meant to be public. It ships in your JavaScript bundle, and anyone can read it in DevTools.
That design is fine, as long as every table has Row Level Security (RLS) turned on with sensible policies. RLS is what decides which rows a given request is allowed to see. As TheCyberSecGuru notes, quoting Supabase's own docs: "Without RLS, anyone with your anon key can access all data."
So the failure chain looks like this:
- An AI tool (or a tired human) generates create table profiles (...).
- Nobody adds enable row level security.
- The app works perfectly in testing, because the bug is that it's too permissive.
- Anyone who copies the anon key out of your bundle can run select * on that table.
If you want the background on how RLS works, our guide on Supabase Row Level Security covers the basics. Below, we'll focus on testing and fixing.
How to check your app (5 minutes)
1. Look at it from the outside, like the researchers did. Open your live site, open DevTools, and search the loaded JavaScript for supabase.co. You'll find your project URL and the anon key next to it. Then, from a terminal, ask for a row from a table you'd consider private:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"What the response tells you:
- An empty array [] or a permission error: good, anonymous users can't read that table.
- Real rows: you have the same problem as those 7,403 sites.
Repeat for every table that holds user data: profiles, users, messages, orders, bookings, leads, waitlist, and so on. Waitlist and contact-form tables are easy to forget and almost always contain emails.
While you're in the bundle, also check that the key you found is the anon key and not the service_role key. The service role bypasses RLS entirely. If it's in your frontend, treat it as leaked and rotate it now: anything in your bundle can be read by every visitor and by every third-party script on the page, as Polymarket's users found out, and bots are already hunting for leaked keys. Our post on rotating your app's secrets without downtime covers the rotation, and API keys exposed in client-side JavaScript covers what else tends to end up in bundles.
2. Check from the inside. In the Supabase SQL editor, list every table in the public schema and whether RLS is on:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Any row with rowsecurity = false is readable (and possibly writable) with the anon key. The Security Advisor in the Supabase dashboard flags these too. Read its warnings rather than dismissing them.
3. Look for "RLS on, but open anyway". RLS being enabled is not the finish line. List your policies:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';Watch out for qual = true on a select policy that applies to anon or public. That's a policy that says "everyone can read everything", which is the same as no RLS. AI tools sometimes generate exactly this to make an error go away.
How to fix it
Turn RLS on for every table:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicWith RLS on and no policies, the table is locked: the anon key gets nothing back. That's a safe default. Now add only the access you actually need.
Write narrow policies. A typical "users can only see and edit their own row" setup:
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Things to keep in mind:
- Scope to authenticated, not public, unless the data is truly public (blog posts, product listings).
- Separate read and write. A table can be safe to read publicly and still need locked-down inserts.
- Public tables still leak columns. If a profiles table is meant to be public for display names, an email or phone column in the same table is public too. Move private fields to a separate table or expose a view with only the safe columns.
- Views can bypass RLS. In Postgres, views run with the owner's privileges by default. Create views with security_invoker = true so the caller's RLS applies.
- Storage is separate. Public buckets serve files to anyone with the URL. Uploaded IDs, invoices and avatars of private users belong in private buckets with storage policies.
Make it the default, not a cleanup job. The researchers' sample spans ten months of new apps, which means this keeps happening on every new table. Make it hard to forget:
- Add a rule to your AI tool's project instructions: every create table in public must be followed by enable row level security and explicit policies.
- Review generated migrations for create table without a matching enable row level security before you merge.
- Re-run the outside check after every schema change, not just at launch.
If you use Firebase instead of Supabase, the same class of mistake exists there in the form of open security rules. See Firebase Security Rules: stop leaving your database open.
If you found exposed data
Fixing the policy stops future reads, but it doesn't undo past ones. Be honest with yourself about what was reachable and for how long. Check your Supabase API logs for unusual bulk reads from unfamiliar IPs, rotate any secrets that were stored in readable tables, and look up your notification obligations if personal data such as emails, IDs or payment details was exposed. In the EU, that likely means GDPR breach rules apply.
Quick checklist
Before you call it done, make sure that:
- Every table in public has rowsecurity = true.
- No select policies use using (true) on tables holding private data.
- Policies are scoped to authenticated and tied to auth.uid().
- Private columns don't live in publicly readable tables.
- Views use security_invoker = true.
- Only the anon key is in your frontend, never service_role.
- Private files are in private storage buckets.
- You re-test from the outside after every schema change.
Check your app from the outside
Our security checklist for AI-built apps covers the other things worth checking before launch. The uncomfortable part of this research is how ordinary the method was: public key, read-only request, data comes back. That's exactly the view an attacker has, and it's the view you should check regularly.
AppSafe scans your live app from the outside. The verified full scan includes read-only checks for open Supabase and Firebase databases and looks for API keys exposed in your JavaScript bundles, and scheduled scans can alert you when something changes after a deploy. Curious what a report looks like? Take a look at the sample report.
Run a free scan of your app now and find out what a stranger can see before someone else does.
