Someone Scanned 30,998 Vibe-Coded Apps. Over Half Let Anyone Read the Database.
Between August 12 and 14, 2026, a team at Reeve ran a passive scan of 30,998 live, deployed vibe-coded web apps. No exploitation, no break-ins — just what any anonymous visitor could observe by loading the page and reading what the app itself handed over.
They published the results on August 19. The headline number:
Of the 3,680 apps with a reachable Supabase backend, 2,096 — 57% — allowed unauthenticated table reads.
Not "had a weak password." Not "could theoretically be attacked." Anyone on the internet could ask the database for its contents, and it answered.
If you built your app with Lovable, Bolt, Replit, v0, or Cursor and it stores data in Supabase, there is a better-than-coin-flip chance this is your app.
The Rest of the Numbers
The same scan found:
- 99% of apps had at least one security finding (most of those were missing security headers — real, but low severity)
- 1,332 apps — 1 in 23 — shipped a secret in a public bundle or config file
- 13% published source maps, handing the readable original source of the app to anyone who requests the
.mapfile - 394 apps exposed tables named after people:
users,customers,members,patients - 452 apps graded D or F overall
A separate three-month study by Symbiotic Security, published August 27, covered 1,967 GitHub repos and 1,072 live apps across five platforms using ten independent scanners — 87,826 vulnerabilities in total. Its findings:
- AI-assisted repos averaged 42.3 vulnerabilities vs. 9.6 for human-only repos — a 4.4x difference
- 98% of live vibe-coded apps had vulnerabilities; 29% had high or critical ones
- 16% allowed unauthenticated users to delete or modify data — not just read it
And an academic audit of 200 deployed repos (arXiv 2606.23130) found 90% vulnerable, with broken access control accounting for 36% of all findings and affecting 75.5% of repos.
Three independent methodologies. Same answer. The single most common failure in AI-built software is that the database has no lock on it.
What "Unauthenticated Table Read" Actually Means
Supabase is a hosted Postgres database with an HTTP API in front of it. Your app talks to that API using a public "anon" key — public by design, sitting right there in your JavaScript bundle. That's not the bug. It's how Supabase is meant to work.
The thing that's supposed to stop a stranger from reading your entire users table is Row Level Security — Postgres policies that say, row by row, who is allowed to see what.
RLS is off by default on new tables.
So when the AI generates a table, wires up the client, and your app works perfectly in testing, everything looks right. You're logged in. You see your data. It works.
What you didn't test is what happens when someone who isn't you asks the same question. And the answer, in 57% of cases, is: the database tells them everything.
Here's what that looks like from the attacker's side — a single command, no tools, no skill:
curl "https://<your-project-ref>.supabase.co/rest/v1/profiles?select=*&limit=5" \
-H "apikey: <your-anon-key>"
If that returns rows, so does every other request for every other row in that table.
The Five Ways This Goes Wrong
Security researchers cataloging Supabase misconfigurations across Lovable, Bolt.new, Cursor, Replit, and v0 apps identified five distinct failure modes. They are worth knowing by name, because four of the five look fine from inside your own app.
1. RLS Off
The most common. The table was created, the policy never was. Supabase's dashboard will actually tell you — an "RLS Disabled" badge — but nothing in your app breaks, so nobody looks.
alter table invoices enable row level security;
create policy "owner_select" on invoices
for select using (auth.uid() = owner_id);
2. Permissive Policy
RLS is on. The dashboard shows a green checkmark. The policy is using (true).
This is worse than having no policy, because it produces a positive signal in the one place you'd think to check. A policy that permits everything is not a policy. It's a formality that satisfies a dashboard.
This is the modal failure on Lovable specifically — and it's the one that most often survives an automated scan that checks for "is RLS enabled?" rather than "does this policy restrict anything?"
3. Partial Coverage
SELECT is locked down. INSERT, UPDATE, and DELETE were never given policies.
Reads are secured, so a quick test looks clean — but a stranger can still write to, alter, or empty your table. This is the 16% from the Symbiotic study. It's the modal failure pattern on Cursor-built apps.
You need a policy per operation:
create policy "owner_insert" on invoices
for insert with check (auth.uid() = owner_id);
create policy "owner_update" on invoices
for update using (auth.uid() = owner_id)
with check (auth.uid() = owner_id);
create policy "owner_delete" on invoices
for delete using (auth.uid() = owner_id);
4. Service-Role Key Exposure
Catastrophic, and it lands in the same bucket as those 1,332 apps shipping secrets in public bundles.
The service_role key bypasses RLS entirely. That's its whole purpose — it's for trusted server-side code. When an AI tool puts it in a client component, a NEXT_PUBLIC_ variable, or a hardcoded config, every policy you wrote stops mattering. The lock is still on the door; the key is taped to the front of it.
If this one applies to you: rotate the key first, then move those operations behind a server route or an Edge Function. Rotating second gives an attacker a window to enumerate what you replaced it with.
5. auth.uid() Misuse
The rarest and the hardest to catch. The policy exists, references the right table, reads correctly at a glance — and fails open for anonymous users.
-- UNSAFE: a null auth.uid() collapses into a match on every row
create policy bad on invoices
for select using (coalesce(auth.uid(), invoices.owner_id) = invoices.owner_id);
An anonymous request has no auth.uid(). The coalesce substitutes the row's own owner_id, the comparison is trivially true, and the policy returns everything.
The fix is an explicit null check:
create policy good on invoices
for select using (auth.uid() is not null and auth.uid() = owner_id);
No linter flags this. No dashboard badge catches it. It requires someone to read the policy and reason about who the subject is.
The Part That Should Worry You Most
Lovable shipped an auto-fix toggle in June 2026. The platform scans every app it hosts. Its CISO went on record in late August about the shared-responsibility model and per-app threat models — a genuinely candid statement, and the security tooling is real.
The 57% figure was measured in August 2026. Two months after auto-fix landed, on a platform that scans everything it ships.
That gap is the entire point. Automated scanning is necessary and it is not sufficient. A scanner checks whether RLS is enabled; it can't tell you whether using (true) reflects your intent. It can flag a policy on profiles; it can't know that the stripe_customer_id column in that table shouldn't be readable by other logged-in users. It matches patterns. Authorization is business logic, and business logic is specific to your app.
The SUSVIBES benchmark (ICML 2026) put a sharp number on this: across 186 real-world tasks and 12 agent setups, the best configuration produced 57% functionally correct solutions — of which only 11.8% were secure. Put differently: 79.3% of the solutions that worked correctly were still vulnerable.
"It works" and "it's safe" are measuring different things. AI coding tools are optimized hard for the first one.
Check Your Own App Right Now
This takes about ten minutes and you don't need to be a security engineer.
1. Open the Supabase dashboard. Go to Authentication → Policies. Any table with an "RLS Disabled" badge is publicly readable today. Start there.
2. Read every policy that exists. Not whether it exists — what it says. If you see using (true), or a coalesce wrapped around auth.uid(), you have a hole. Check that each table has policies for insert, update, and delete, not just select.
3. Test as a stranger. Open a private browser window, log out, and hit your own REST endpoint with your anon key (the one already visible in your bundle):
curl "https://<your-project-ref>.supabase.co/rest/v1/<table>?select=*&limit=5" \
-H "apikey: <your-anon-key>"
An empty array [] is what you want. Rows mean that table is open to the world.
4. Grep your built bundle for secrets. Build the app, then search the output:
npm run build
grep -rEi "service_role|sk_live|sk-[A-Za-z0-9]{20}|AKIA[0-9A-Z]{16}" dist/ .next/ build/ 2>/dev/null
Any hit is a key that has already shipped to every visitor. Rotate it, then move it server-side.
5. Turn off source maps in production. If .map files are deployed, your full original source — comments, internal endpoints, TODOs and all — is one request away.
6. Check the table names. users, profiles, messages, customers, patients. These are the five names AI generators converge on, and they're the first five an opportunistic scanner tries. They also tend to hold the data that actually hurts when it leaks.
What a Checklist Can't Do
Everything above will find the obvious holes. Run it — most apps in that 57% would be fixed by step one alone.
But the failures that end companies aren't usually the obvious ones. They're the policy that's correct for the schema you had three features ago. The admin endpoint that checks whether you're logged in but not whether you're an admin. The webhook handler that trusts an unsigned payload. The profiles table with tight RLS and one column that shouldn't be in it. Nothing in a rule set knows your app well enough to catch those, because they require understanding what your app is for.
That's the review. A human reading your access-control logic against the actual shape of your product and asking, table by table and route by route, who is allowed to see this, and does the code enforce that?
Your AI wrote those policies. It wrote them without knowing what your data is worth.
Get your access control reviewed before someone else tests it for you.