If you've followed our scanning batches, you've seen the pattern. In our most recent run of 28 Supabase-backed apps (Lovable, Bolt, Replit), 5 apps had at least one table readable by anyone holding the public anon key. The worst single app had 11 tables exposed. Across the cohort: 13 individual table-leak findings.
That's 18% of apps with a critical RLS misconfiguration on a single failure mode. Not a long-tail bug — a recurring shape.
The model
Supabase exposes a single Postgres database via PostgREST. There are two keys:
anon— a JWT withrole: anon. Designed to ship to the browser. Every visitor of your app holds it.service_role— a JWT withrole: service_role. Bypasses RLS entirely. Must stay server-side (any backend — Node, Python, Edge Function, etc.).
Once a user logs in, their browser presents a third JWT signed by Supabase Auth that includes their sub (user ID). That's the value auth.uid() reads inside RLS policies.
All requests hit the same PostgREST endpoint. What the database returns depends entirely on which JWT is presented and what RLS policies say about it.
What RLS actually controls
RLS = Row Level Security, a Postgres feature (not a Supabase invention). For each table you can:
- Disable RLS entirely — any role with
SELECTgrant on the table reads everything. The Supabaseanonrole hasSELECTon everything inpublicby default. - Enable RLS with no policies — the default-deny state. Even the table owner reads nothing through RLS. (
service_rolestill bypasses.) - Enable RLS + one or more policies — each policy is a SQL expression that filters rows visible to a given role/operation. Common:
CREATE POLICY "owner_read" ON x FOR SELECT USING (auth.uid() = owner_id);
Two failure modes, both common:
- RLS off. Every table row is anon-readable.
- RLS on, permissive policy like
USING (true). Same effect — every row is anon-readable.
Why "the default" is more nuanced than you'd think
Two ways to create a table in Supabase:
- Dashboard → Table Editor. The "Enable Row Level Security" checkbox defaults to checked (since 2024). You'll get RLS-on with no policies — table is locked by default until you add a policy. This is the safe path.
- SQL Editor / migrations / CLI
supabase db push. PlainCREATE TABLEstatements get RLS-off, like vanilla Postgres. The dashboard shows a yellow warning banner once it notices, but the table is exposed in the meantime.
AI assistants — Lovable, Bolt, v0, Cursor with Supabase MCP — overwhelmingly go through the SQL path because that's what they're trained to write. So the apps we scan, which are predominantly AI-scaffolded, end up with the SQL-path default: RLS off.
The failure is per-table, not per-project
The pattern we see most often: a developer follows the official "build a Twitter clone" tutorial, enables RLS on the profiles table when prompted, writes a couple of policies. They internalize "I've configured RLS." Three months later they (or their AI assistant) add invoices, customer_contacts, subscription_history tables via SQL — RLS off, no policies, anon-readable.
One real example from our last batch: an app with profiles RLS-gated correctly, but 11 other tables added later — all wide open. Customer emails, account passwords (in a literal column), booking requests with phone numbers, chat messages.
Three concrete fixes
1. The "every new table" habit
If you write SQL by hand, make this the snippet you reach for:
CREATE TABLE x (id bigint primary key, owner_id uuid, ...);
ALTER TABLE x ENABLE ROW LEVEL SECURITY;
CREATE POLICY "owner_can_select" ON x
FOR SELECT USING (auth.uid() = owner_id);
CREATE POLICY "owner_can_modify" ON x
FOR ALL USING (auth.uid() = owner_id);
The ALTER + 2 policies are the minimum viable scaffold. Adjust the predicate per table.
2. A pre-deploy lint
Add a CI check that fails the build if any public-schema table has RLS off. Run this against your migration's resulting state, not against prod:
SELECT n.nspname, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = 'public'
AND c.relrowsecurity = false;
Empty result = pass. Any rows = fail the build with the offending table names.
3. Continuous external scan
The CI check catches new tables before deploy. An external scan catches drift after deploy — schema migrations, manual SQL, third-party functions. We do this for free on one target; the paid plans run it weekly and email you if anything new broke.
The platform fix
If you're a Lovable / Bolt / Cursor template author or work on the Supabase team: surface the check above when an AI scaffold creates a new table via SQL. Five milliseconds of work, would prevent more critical disclosures than any single change in the AI-tooling ecosystem right now. The friction is purely social — devs see a "Enable RLS now?" prompt, click yes, write a policy. Don't ship.
The ecosystem will fix this eventually. Until then, scan before you launch.