The Supabase RLS Leak in Bolt and Lovable Apps
98% of scanned Supabase-backed vibe-coded apps had a flaw. The exact non-destructive, count-only method to check whether your own tables are public.
TL;DR. Your Supabase anon key is public by design — it ships inside your JavaScript bundle and anyone can read it. The only thing standing between that key and your database is Row-Level Security. When an AI builder creates tables and forgets the policies, every row is readable by anyone with a browser. A scan of 1,072 Supabase-backed vibe-coded apps found 98% had at least one vulnerability and 39 sites allowed full unauthenticated reads. This post gives you the exact, non-destructive, count-only method Soatech built into its own audit tooling — three curl commands, ten minutes, zero rows retrieved — plus the SQL that actually fixes it and the two gotchas that make RLS look enabled while protecting nothing.
What a "Supabase RLS leak" actually is
A Supabase RLS leak is a table that any anonymous visitor can read through your project's public REST API, because Row-Level Security is either disabled on that table or governed by a policy that grants access to everyone. It is not a stolen credential, not a misconfigured firewall, and not something an attacker needs tooling to exploit. It is the default state of a Postgres table that nobody wrote a policy for.
Two facts have to be held at the same time for this to make sense:
- The anon key is meant to be public. Supabase ships it to the browser deliberately. It is a JWT with the
anonrole baked in, it lives in your client bundle, and anyone who opens DevTools can copy it in five seconds. Rotating it does not help. It was never designed to be a secret. - Therefore all of your access control lives in the database. Per the official Supabase RLS documentation, Row-Level Security policies are the enforcement layer for anything exposed through the auto-generated REST API. No policy means no enforcement.
The failure mode follows directly: a table created without ENABLE ROW LEVEL SECURITY and granted to the anon role is a public API endpoint that returns your users' data. Not "eventually exploitable." Public, right now, to anybody who types the URL.
Supabase's dashboard flags unprotected tables. AI builders generate the schema faster than most founders read the dashboard.
How common this is
In a crawl of 65,643 URLs, Symbiotic Security scanned 1,072 Supabase-backed applications built with Lovable, v0, Bolt, Replit, Windsurf and Tempo. The results:
| Finding | Sites affected |
|---|---|
| At least one vulnerability | 98% (only 26 sites clean) |
| At least one critical vulnerability | 16% |
DELETE allowed without authentication | 172 |
PATCH allowed without authentication | 172 |
| Full unauthenticated read access | 39 |
| Sensitive columns exposed (emails, password hashes, tokens) | 34 |
Unauthenticated INSERT allowed | 14 |
| Anon key exposed in client JS | 308 |
| CORS misconfiguration | 197 |
| Email confirmation disabled | 69 |
6,185 findings across 1,072 sites — 5.9 per site. The most-exposed table names were the ones you would least want exposed: leads, profiles, contact_submissions, admin_users, payments, user_roles, chat_messages, trading_user_portfolio.
This is a category-level problem, not a vendor-level one. CVE-2025-48757 documented the missing-RLS pattern in Lovable specifically, but the same shape appears in every builder on that list. The upstream cause is measurable: per Veracode's Spring 2026 GenAI code security research, 45% of AI-generated code contains security vulnerabilities even when the syntax is clean. The code runs. It just does not defend itself — the same structural gap covered in 5 ways Bolt and Lovable apps fail in production.
Rules of engagement — read this before you run anything
The check below is deliberately narrow, and the narrowness is the point.
- Run it only against applications you own or are explicitly authorized to test. Probing someone else's app is unauthorized access in most jurisdictions regardless of how easy it is. "The key was public" is not a defence.
- The method is count-only and read-only. It requests
limit=0with thePrefer: count=exactheader, which asks PostgREST to return the row count in a response header and an empty array as the body. No rows are retrieved. Nothing is written, patched, or deleted. - Do not escalate. If you find an exposed table, stop at the count. Pulling rows to prove a table is readable turns a diagnostic into an exfiltration, and the count already proved it.
- Never test
INSERT,PATCHorDELETEagainst production. Those belong in a staging copy of your own database.
Soatech's own production-readiness tooling is capped at exactly these constraints on purpose: list the exposed tables, request a count, stop. The method was validated against the wintura.ai production build and publicly shared AI-app exports — not by poking strangers' databases.
Need help building this?
Architect-led, AI-accelerated MVP delivery in weeks, not months. Let's scope your project.
Get in TouchThe 10-minute check
Three steps. You need a terminal and your app's public URL.
Step 1 — pull your Supabase URL and anon key out of your own bundle
Both are in the JavaScript your app already serves. Open your deployed site, open DevTools, and search the loaded scripts for supabase.co. Or do it from the terminal:
# 1. list the JS bundles the page loads
curl -s https://your-app.com | grep -oE '<script[^>]+src="[^"]+\.js[^"]*"'
# 2. grep a bundle for the project URL and the anon JWT
curl -s https://your-app.com/assets/index-abc123.js \
| grep -oE 'https://[a-z0-9]{16,30}\.supabase\.co|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' \
| sort -u
You will get something like https://abcdefghijklmnop.supabase.co and a long eyJ... JWT. That JWT is your anon key.
The anon key being visible is expected and fine. What is not fine is finding a service_role key here, an sk_live_ Stripe key, or an OpenAI sk- key — anything in the bundle is public. Decode the JWT payload in any JWT viewer and confirm the role claim reads anon, not service_role.
Step 2 — ask the REST root which tables are exposed
The PostgREST root endpoint returns an OpenAPI description of everything the calling role can see. With a valid anon key, that is a complete inventory of your publicly-reachable tables:
SUPABASE_URL="https://abcdefghijklmnop.supabase.co"
ANON_KEY="eyJhbGciOi..."
curl -s "$SUPABASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
| grep -oE '"/[a-zA-Z0-9_]+":' | tr -d '":/' | sort -u
Output is a table list: profiles, leads, payments, rpc, and so on. Ignore rpc. Everything else is a table or view reachable over HTTP.
Seeing a table here is not yet a leak — it means the table is exposed in the API schema and the anon role holds a grant on it. Whether it returns data is step 3.
Step 3 — count-only exposure probe
For each table, ask for zero rows and an exact count:
curl -s -o /dev/null -D - \
"$SUPABASE_URL/rest/v1/profiles?select=*&limit=0" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Prefer: count=exact"
-o /dev/null -D - discards the body and prints only the response headers, which is all you need. Read the content-range header:
HTTP/2 200
content-range: */4812
The number after the slash is how many rows an unauthenticated stranger can read from that table. 4812 means 4,812.
How to read the result
| Response | What it means | Verdict |
|---|---|---|
200 + content-range: */N where N > 0 | The anon role can read N rows without logging in | Critical leak |
200 + content-range: */0 | Either the table is genuinely empty, or RLS is filtering every row out | Ambiguous — re-run against a table you know has rows |
401 / 403 with "code":"42501" | permission denied for table — the anon role has no grant at all | Safe |
404 | Not exposed in the API schema | Safe |
The ambiguous case matters. When RLS is enabled with no matching policy, PostgREST returns 200 and an empty result rather than an error — a locked-down table and an empty table look identical from outside. Compare the count against the row count in your Supabase dashboard: if the probe returns */0 on a table the dashboard says holds 900 rows, RLS is working. If the two numbers match, it is not.
Run step 3 against every table from step 2, not just the obvious ones. The Symbiotic scan's most-exposed list is a good prompt for what people forget: contact_submissions, user_roles and admin_users are rarely the tables founders think to check.
The fix: enable RLS and write a real policy
Enabling RLS is one statement. It is also the half of the fix that gets done alone, which is where the trouble starts.
-- Step 1: turn RLS on. With no policies present, this denies everything.
alter table public.profiles enable row level security;
-- Step 2: grant back exactly the access your app needs.
create policy "profiles are selectable by their owner"
on public.profiles
for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "profiles are updatable by their owner"
on public.profiles
for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
Three details that are easy to miss:
- Policies are per-command. A
for selectpolicy does nothing forUPDATEorDELETE. Write one per operation you want to allow, and none for the ones you do not — that is how 172 sites ended up accepting unauthenticatedDELETE. usingfilters what can be read;with checkvalidates what can be written.UPDATEandINSERTpolicies needwith check, or a user can update a row they own into a row they do not.- Wrap
auth.uid()in a subselect.(select auth.uid())is evaluated once per query instead of once per row — on a table with real volume, the difference between a fast query and a table scan.
Gotcha 1: using (true) protects nothing
This is the most common "I enabled RLS" that is not RLS:
-- Looks secure in the dashboard. Is a public API endpoint.
create policy "enable read access for all users"
on public.profiles for select
using (true);
true matches every row for every role, including anon. The dashboard shows RLS enabled with one policy attached, the warning badge disappears — and the step 3 probe still returns every row. AI builders generate this policy constantly, because it is the fastest way to make a broken screen render again.
Gotcha 2: enabling RLS with no policy breaks your app, and people revert it
The opposite failure. alter table ... enable row level security with zero policies denies everything, including the reads your own frontend depends on. The app goes blank, the founder assumes RLS was the wrong move, and the change gets reverted — or replaced with using (true), which lands you in gotcha 1. Correct sequence: write the policies first, then enable RLS, then re-run step 3 to confirm the anonymous count went to zero while your logged-in app still works.
Gotcha 3: service_role in the browser bypasses all of it
The service_role key is designed to bypass RLS entirely — that is its purpose, for server-side jobs. If it reaches the client bundle, every policy you just wrote is decorative:
// WRONG — NEXT_PUBLIC_* is compiled into the browser bundle.
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, // ships to every visitor
);
// RIGHT — anon key on the client, service_role only in Server Actions / route handlers.
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
One more: a view does not inherit RLS from its underlying tables unless declared with (security_invoker = true). Without it, the view runs as its owner and returns rows the caller should never see. Check views as well as tables — step 2 lists both.
What this check does not cover
The three curl commands find read exposure on tables the anon key can enumerate. That is one row in a much longer table. They say nothing about write exposure, auth hardening, webhook signature verification, rate limiting, or the 34-site category where sensitive columns sat inside otherwise-plausible tables. For the full surface, work through the 18-point Lovable-to-production security checklist — RLS is items 1 and 2 of 18. The write-ups at Vibe App Scanner and this RLS guide for vibe-coded apps cover adjacent ground.
If you would rather have the answer written down and severity-ranked than assembled by hand, that is the Production Audit: €1,500 fixed, 3 days, a written diagnosis with no code changes, fee credited toward any build within 30 days. If you already know the answer and want it fixed, the Production Lift is €3,500 fixed, one week, RLS and tenant isolation included.
Frequently Asked Questions
Is it safe to run this against my own live production app?
Yes. Step 3 sends a GET with limit=0, which returns an empty body — no rows leave your database, and nothing is written or modified. The only side effect is one row-count query per table. Do not extend the method to INSERT, PATCH or DELETE against production; test those on a staging copy.
My anon key is visible in the bundle. Should I rotate it?
Rotating solves nothing, because the replacement key will be equally public — the anon key is designed to be shipped to browsers. The real question is what that key can reach. Run steps 2 and 3, fix the policies, and the exposed key becomes harmless, which is the state Supabase intends. Rotate immediately only if you find a service_role key or a third-party secret in the bundle.
The dashboard says RLS is enabled on every table. Am I fine?
Not necessarily. The badge confirms RLS is switched on; it does not evaluate whether your policies are meaningful. A single using (true) policy satisfies the badge and still grants universal read access. The count-only probe tests behaviour rather than configuration, which is why it is worth running even when the dashboard is green.
How long does fixing this take on a typical Bolt or Lovable app?
For a prototype with 8–15 tables and a single tenancy model, writing and verifying policies is usually half a day to two days — most of it spent deciding what the access rules should be rather than typing SQL. It grows quickly once roles, shared workspaces or admin surfaces appear, because every policy has to be reasoned about per command and per role. The Production Lift covers this inside a fixed €3,500, one-week engagement for codebases up to 30K LOC and 10 routes.
I have no prototype yet — how do I avoid this from the start?
Design the tenancy model before the schema. Decide what a tenant is, put the tenant key on every table, and write the policies in the same migration that creates the table. That ordering is part of the Technical Blueprint and MVP Sprint tracks on the build page; the calculator gives an indicative range. Retrofitting isolation into a schema that was not designed for it is consistently the most expensive part of a production lift.
Ran the check and did not like the numbers? The Production Audit is €1,500 fixed, 3 days — a written, severity-ranked diagnosis of your Supabase policies, auth flow, and client bundle, delivered by the architect who ships the code. No code changes, no retainer, and the fee credits toward any Soatech build within 30 days.
Related Articles
Lovable to Production: The 18-Point Security Checklist
CVE-2025-48757 exposed 10% of Lovable apps. This 18-point checklist covers RLS, auth, webhooks, and deployment — what Lovable doesn't generate.
5 Ways Bolt & Lovable Apps Fail in Production
Real anti-patterns from Bolt/Lovable exports that fail when paying users arrive: app-layer tenancy, mock auth, missing webhook verification, generic error handlers, no a11y. Each with the production fix.
Bolt to Production: The €3,500 Fixed-Price Playbook
Ship your Bolt.new prototype to production in 1 week. Auth, multi-tenant RLS, Stripe webhooks, e2e tests — €3,500 fixed price.
Ready to build something great?
Architect-led, AI-accelerated. Let's turn your idea into a shipped product.
Built by the studio behind wintura.ai — a live, multi-tenant B2B SaaS on Next.js 16 + Claude Sonnet 4.6.