# 24,664 AI-built apps sent database records to any visitor. I built a one-command check that proves yours does not.

> Source: <https://dev.to/techdevfun/24664-ai-built-apps-sent-database-records-to-any-visitor-i-built-a-one-command-check-that-proves-1870>
> Published: 2026-11-15 12:11:13+00:00

In November 2026, German security firm Crissy Field (Hamburg), working with the newspaper Handelsblatt, published research on AI-built apps. The numbers are the reason I built this tool, so here they are first, with the caveats attached.

The researchers scanned 159,041 Lovable projects found in Common Crawl (apps created December 2025 to September 2026). Of those, 34,545 used Supabase as their backend. **24,664 websites sent database records to the browser as soon as the page loaded, without any login or interaction.** With further read-only requests using the same public key the app itself ships, the researchers found records containing email addresses in **10,560 tables across 7,403 sites (30%)**. Worst cases, each a separate app: one service provider exposed more than 1,000 identity records (passport numbers, expiry dates, nationalities); one lifestyle forum exposed more than 200,000 private chat messages and 80,000 profiles; one app exposed about 280,000 customer emails.

Read those numbers honestly, because the researchers are explicit about the limits:

The same class of bug had already been disclosed eight months earlier as CVE-2025-48757 (March 2025, CVSS 9.3): researchers scanned 1,645 Lovable showcase sites and found 170 with tables open to anonymous reads. Lovable added a Security Scanner and an AI Security Reviewer after that, and the exposure repeated at a larger scale in November. The lesson is not "run a scanner once". It is "prove, on every deploy, that an anonymous request returns zero rows".

I built a small tool that does exactly that. It is called [vibeleak](https://github.com/techdevfun/vibeleak), and it is a zero-dependency TypeScript CLI. MIT licensed. It starts from only the deployed app URL, finds the Supabase project URL and anon key in the JavaScript the app already ships, then probes each table and storage bucket as an anonymous visitor. One command, one verdict: CLEAN, LEAK, or REVIEW, with a JSON report and a baseline diff so a closed table that opens again fails CI.

When an AI tool builds an app on Supabase, it ships a public anon key in the frontend JavaScript. That is by design: the browser needs the key to talk to the database. The only thing standing between a stranger and your tables is Row Level Security. If RLS is off, or a policy says `USING (true)`, the anon key reads the whole table, and the app helpfully sends the rows to any visitor on page load.

Nobody checks this because the app works. The login page looks right, the dashboard loads, the data is there for the right user. The failure is invisible from inside the app. You only see it when you make the same request the app makes, without logging in, and count the rows that come back.

That is what vibeleak does. It fetches the app the way any visitor does, extracts the Supabase URL and anon key from the shipped JavaScript (the same code every browser downloads), enumerates the tables through the PostgREST OpenAPI document, and probes each one with an anonymous GET limited to one row. It stores column names and a 0/1 rows-visible flag only. It never stores row values. It never writes anything: GET and HEAD only, no POST, no RPC calls, no writes of any kind.

Five commands:

```
vibeleak scan <app-url> [--i-own-this-app] [--out report.json]
vibeleak probe --url <project-url> --anon-key <key> [--tables a b]
vibeleak check [--report report.json] [--baseline baseline.json]
vibeleak diff --baseline baseline.json [--report report.json]
vibeleak init [--dir .]
```

`scan`: fetch the app URL, collect same-origin JavaScript (max 10 files, 2 MB each), extract the Supabase URL and anon key, then probe. If a `service_role` JWT or `sb_secret_` key is found in the shipped bundle, that is a CRITICAL finding and probing stops: an admin key in the frontend makes table probing meaningless.`probe`: the same engine with an explicit project URL and anon key, for apps with no public frontend or when the key is already known.`check`: read a report, compare it with a baseline and the `vibeleak.json` allowlist. Exit 0 CLEAN / 1 LEAK / 2 input error / 3 REVIEW. A table that was clean in the baseline and now leaks is a regression and fails.`diff`: compare the current report with a baseline: new leaks, regressions (closed then re-opened), and fixed tables, one line each.`init`: write a sample `publicTables` allowlist, `failOn`) and an empty baseline. It never writes a key into a file: keys come from a flag or `VIBELEAK_ANON_KEY` and are never logged.
Verdict rules, evaluated in order:

`password`, `secret`, `ssn`, `passport`, `token`, `api_key`, name match only, values never read) - MEDIUM, REVIEW.`--tables`) - LOW, REVIEW.
Only scan apps you own or are authorised to test in writing. `scan` and `probe` require `--i-own-this-app` (or `VIBELEAK_I_OWN_THIS_APP=1`). Without it the command exits 2 and sends no request. Only anon keys are accepted: a `service_role` or `authenticated` JWT is refused before any request, and an `sb_secret_` key is never sent anywhere.

A typical CI flow:

```
vibeleak scan https://my-app.example.com --i-own-this-app --out report.json
vibeleak check --report report.json --baseline vibeleak.baseline.json --fail-on high
vibeleak diff --baseline vibeleak.baseline.json --report report.json
```

The GIF above is a real terminal session against synthetic local fixtures: a fake Lovable-style app and a fake PostgREST server on 127.0.0.1. No real app and no real Supabase project was probed. The anon key is a self-signed synthetic JWT, and the row values are fake.

The leaky fixture scans LEAK:

``` bash
$ vibeleak scan <app-url> --i-own-this-app --out report-before.json
scanning http://127.0.0.1:37965 ...
project: http://127.0.0.1:37965 (Supabase URL found in shipped JavaScript)
anon key: found (role anon)
tables: 3 enumerated via OpenAPI
  orders: 0 row(s) visible
  profiles: 1 row(s) visible, columns: id, email, passport_no
  public_posts: 1 row(s) visible, columns: id, title, body (allowlisted: public by design)
storage buckets: 3
  uploads: object listing allowed
  public-assets: object listing allowed (allowlisted)
  private-files: not listable
findings:
  LEAK: profiles (1 row visible, columns: id, email, passport_no) - anonymous GET returns 1 row(s); table is not allowlisted
  LEAK: uploads - anonymous object listing is allowed; bucket is not allowlisted
verdict: LEAK - an anonymous visitor can read data (exit 1)
exit: 1
```

Then the fixture is "fixed" (RLS enabled, bucket locked) and the same scan is CLEAN:

``` bash
$ vibeleak scan <app-url> --i-own-this-app --out report-after.json
scanning http://127.0.0.1:37311 ...
project: http://127.0.0.1:37311 (Supabase URL found in shipped JavaScript)
anon key: found (role anon)
tables: 3 enumerated via OpenAPI
  orders: 0 row(s) visible
  profiles: 0 row(s) visible
  public_posts: 1 row(s) visible, columns: id, title, body (allowlisted: public by design)
storage buckets: 3
  uploads: not listable
  public-assets: object listing allowed (allowlisted)
  private-files: not listable
verdict: CLEAN - anonymous read clean (not a full audit; see Honest limits)

$ vibeleak check --report report-after.json --baseline report-before.json
checking report-after.json against baseline report-before.json ...
public_posts still public by design (allowlisted)
no regressions vs baseline
verdict: CLEAN - anonymous read clean (not a full audit; see Honest limits)

$ vibeleak diff --baseline report-before.json --report report-after.json
private-files: CLEAN -> CLEAN
public-assets: ALLOWLISTED -> ALLOWLISTED
uploads: LEAK -> CLEAN (fixed)
orders: CLEAN -> CLEAN
profiles: LEAK -> CLEAN (fixed)
public_posts: ALLOWLISTED -> ALLOWLISTED
2 fixed, 0 new leaks, 0 regressions
```

The full transcript (54 lines, real CLI output) and the GIF are in `demo/` in the repo. The test suite is 47/47 passing (36 unit + 11 e2e, including consent refusal with zero requests sent, service_role refusal before any request, redaction of row values from reports, and baseline diff regressions). CI is green on Node 20 and 22.

The npm package is not published yet, so for now install from source:

```
git clone https://github.com/techdevfun/vibeleak.git
cd vibeleak
npm install
npm run build
node dist/cli.js --help
```

Or link the binary locally:

```
npm link
vibeleak --help
```

Requires Node.js 20 or newer, zero runtime dependencies.

Checked 15 November 2026. The wedge is the first row: no other tool starts from only the deployed app URL, extracts the Supabase URL and anon key from shipped JavaScript, and probes anonymously.

| Tool | What it does well | Where it is weak (the wedge for vibeleak) | 
|---|---|---|
| supabase-rls-check (Python CLI, consent-gated; [PyPI](https://pypi.org/project/supabase-rls-check/) , v0.6.3 announced 14/11/2026) | `probe` with an anon key via PostgREST;`scan-env` finds service_role keys in code;`--fail-on` for CI; refuses service_role before requesting | Catalog checks need a manual round-trip: export `check.sql` , paste it into the SQL Editor, export a CSV, then analyse.`probe` needs the user to supply the project URL and anon key. No baseline diff for anonymous exposure in one command. | 
| rls-doctor (Node CLI, `check` /`probe` /`shadow` /`prove` ;[npm 0.5.8, published 11/10/2026](https://www.npmjs.com/package/rls-doctor) ) | Behavioural probe: impersonates app roles in a rolled-back transaction and counts rows per identity; deep catalog analysis (policy OR-combine, WITH CHECK fallback, owner bypass) | Requires `DATABASE_URL` , a direct Postgres connection. Lovable and vibe-code users usually do not have one. No anon-key-only mode, no start from a deployed app URL, no frontend bundle scan. | 
| Lovable Security Scanner + AI Security Reviewer (in-platform, added after CVE-2025-48757) | Runs where the app was built | Lovable only. No CI run, no exported evidence, no use for Bolt, v0, or other Supabase apps. Lovable's own docs say the scan does not guarantee complete security. | 
| Supabase Security Advisor (dashboard) | Flags tables with RLS disabled ( `rls_disabled_in_public` ) | Dashboard only. No CLI or CI, and it reads the catalog: it never proves what an anonymous request actually returns. | 

Honesty first: vibeleak owns a narrow slice, and the rivals are real.

`SECURITY DEFINER` view or a function with its own RLS behaviour is invisible to an anonymous table probe.
vibeleak is the **outermost layer**: prove what a stranger can read. It complements, not replaces, a catalog audit. Use it as the gate on every deploy, and use the catalog tools for the deeper audit.

`GET /storage/v1/object/list/<bucket>`), because the tool is read-only by design. Real Supabase object listing is a POST endpoint, so a GET probe can return 405 there. Treat a non-listable result as "not proven listable", not as "proven private". The bucket list itself (`GET /storage/v1/bucket`) always works.` probe --tables` with an explicit key when extraction fails.
The November 2026 numbers are not a story about one bad app. They are a story about a missing gate: AI writes the app, nobody proves the database is locked, and the app ships. The same bug class was disclosed in March 2025 and repeated at a larger scale eight months later, because a one-off scan does not stop a regression. A gate that runs on every deploy does, if it proves the exact thing an attacker would do: make an anonymous request and count the rows.

If you ship an AI-built app with real user data, prove it is locked before it goes live, and prove it again on every deploy:

[github.com/techdevfun/vibeleak](https://github.com/techdevfun/vibeleak)

Feedback welcome in the repo issues. The roadmap (probe-from-config mode for `check`, a real POST storage probe, a tighter demo GIF) is in the README.
