Your app demoed fine. It’s been live for weeks. Nobody has reported a bug, because there is no bug in the usual sense: the database holding every user’s data might already be answering queries from anyone on the internet, and your own testing would never have shown you that.
Why this is invisible from the inside
Supabase tables ship with Row Level Security off by default. A brand-new table with RLS disabled will return every row to anyone who asks, using nothing more than the public anon key that’s already sitting in your shipped JavaScript by design. Enabling RLS and writing the policies that say who can read or write what is a separate, manual step, and AI builders generating your schema (Lovable, Bolt, and agent-written Supabase integrations in Cursor and Claude Code all do this) tend to get you a working table, not a locked one. Getting the demo to run doesn’t require the policy step, so it’s easy for it to just not happen.
The reason you never notice: you test your own app logged in as yourself, hitting your own data through your own UI, which behaves identically whether RLS is on or off. The gap only shows up to a request your UI never sends, an anonymous or cross-account query straight against the table. Unless you go looking for that request specifically, everything looks correct.
This already happened at scale
In 2025, researchers Matt Palmer and Matan Getz disclosed CVE-2025-48757 (CVSS 9.3) after scanning 1,645 publicly deployed Lovable apps: 170 of them, about 1 in 10, had exposed databases reachable through exactly this gap, together exposing emails, API keys, payment details, and other personal data through 300+ vulnerable endpoints. The mechanism was always the same table: a Supabase project generated with the anon key wired up correctly and no working policy behind it, so the “public” key was, in practice, a master key.
Check your own tables
Run this in the Supabase SQL editor against your project:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
Any row where rowsecurity comes back false is a table with no protection at all, regardless of what the dashboard’s policy list shows for it.
Then confirm it from the outside, not the dashboard. Query a table with only the anon key, no session, the way an attacker actually would:
curl "https://<project>.supabase.co/rest/v1/<table>?select=*" \
-H "apikey: <anon-key>"
If that returns rows you didn’t expect a stranger to see, the policy isn’t doing its job, whether or not RLS shows as “enabled.”
Fixing it
Enable RLS on every table that holds user data, then write an explicit policy per operation rather than one broad rule:
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users read own profile"
ON profiles FOR SELECT
USING ((select auth.uid()) = user_id);
Wrap auth.uid() in (select ...) as shown, not bare. Postgres caches the wrapped form once per query instead of re-evaluating it per row, and on a table with real traffic that difference is measurable, not cosmetic.
Do this for every operation you actually use (SELECT, INSERT, UPDATE, DELETE) on every table that isn’t meant to be fully public. A table with RLS enabled but zero policies denies everything by default, which will surface immediately as a broken feature, so you’ll find the gaps in your policy coverage fast.
Re-run the anon-key curl check afterward. “The dashboard says RLS is on” and “an anonymous request actually gets refused” are two different claims, and only the second one means you’re done.
Comments
Sign in to join the conversation.
No comments yet — be the first.