Key Takeaways
- A scan of live vibe-coded apps in August 2026 found that 57% of the Supabase databases it could test were readable without a login.
- The usual causes are an open database, a key shipped to the browser, or a route that never checks who is asking, and all three are misconfigurations you can test for.
- Each of the 12 checks below gives you a command, shows what a failing result looks like, and says how long the fix takes.
- Half of the checks run against the built app or the live URL, because a code scanner cannot see an open database.
- A coding agent with a terminal can run ten of the twelve for you (the prompt is below), and three of them belong in CI.
In August 2026 a researcher ran automated checks against about 31,000 live apps built with Lovable, Bolt, v0 and Replit. Of the Supabase databases the checks could reach, 57% returned their data to anyone who asked, with no login (Reeve, August 2026). Those databases were open to the internet by default, and nobody had switched that off.
This checklist is written for that kind of failure. It has twelve checks. Each one gives you a command to run, shows you what a failing result looks like, and tells you how long the fix takes. Six of the twelve run against your built app or your live URL instead of your source code, because that is where the breaches in this post happened.
Why Vibe-Coded Apps Leak
The code that AI writes is roughly as safe as the code people write. A March 2026 study compared tens of thousands of AI-written code units in real repositories with human-written ones and found the AI code set off slightly fewer scanner warnings and contained fewer hardcoded secrets (Mao et al., March 2026).
The breaches come from what the app ships with. Moltbook is the clearest example this year. It was an AI social network launched in late January 2026 by a founder who said he “didn’t write one line of code.” Within four days, researchers at Wiz found the database key in the site’s JavaScript and row-level security turned off, so anyone could read and write the whole database. That exposed about 1.5 million API tokens along with tens of thousands of email addresses (Wiz, February 2026).
The same month, an exam platform that Lovable had featured on its own Discover page leaked 18,697 user records. The app’s login check was written backwards, the database had no row-level security, and nothing checked which role a user had. The researcher who found it called the main bug “a classic logic inversion that a human security reviewer would catch in seconds” (The Register, February 2026). Two months later the platform itself had a flaw that let any free account read other users’ projects.
Those stories share one cause: getting an app in front of users used to mean configuring authentication yourself, because nothing worked until you did. AI tools removed that step, so the app keeps running on its default settings, and the default for a new database is open. That is why this checklist spends more time on your deployed app than on your source code.
How to Use This Checklist
Run all twelve before your first real signup, again before you take a payment, and once a month after launch. You do not have to run them by hand: the prompt at the end hands the whole list to your coding agent and gets you a report.
The commands assume a Next.js App Router project, npm, and a Unix shell (Windows: Git Bash or WSL). Checks 3 to 5 are Supabase-specific, because Supabase is what Lovable, Bolt and most vibe-coding tools hand you, and open Supabase tables are the failure behind the opening scan. If your database sits behind your own API (Prisma, Drizzle, Neon), skip them and lean on check 7; on Firebase the equivalent failure is a Firestore rule that allows reads to everyone. Checks 6 and 7 use Clerk’s names, with Auth.js, Better Auth and Supabase Auth in the table under Authorization; other package managers are in check 8, Vite in check 2. Values such as app.example.com, abcdefghijkl.supabase.co, the keys and the ids are examples; use your own, and take request URLs and cookies from DevTools (Network, right-click a request, Copy as cURL) instead of typing them. If your app lives inside Lovable, export it to GitHub first, because seven of the checks read the code. Four scanners are separate installs; Homebrew has all of them, and Linux and Windows get release binaries from each project.
brew install gitleaks trufflehog osv-scanner semgrep Most of the damage in this post lives outside the source code: a database setting, a key that ended up in a deployed file, a route that answers the wrong user. A scanner that reads your repo cannot see any of that. That is why six of the twelve checks talk to the built bundle or the live URL, and why the code scan comes last.
Secrets: Three Places Keys Leak (Checks 1–3)
Keys leak in three places: your git history, the JavaScript you send to the browser, and the wrong kind of database key. The third is Supabase-specific.
AI assistance makes this worse. GitGuardian’s 2026 report found that commits written with Claude Code leaked a secret about twice as often as the average public commit (GitGuardian, March 2026). The same report found that most keys leaked years ago still worked, because nobody had rotated them. So the fix for every check in this section starts the same way: rotate the key first, then clean up.
Check 1: Is there a secret anywhere in your git history?
gitleaks git . --redact -v
trufflehog git file://. --results=verified --fail Fails when: gitleaks prints a finding and exits with code 1. The older detect subcommand is deprecated. The trufflehog line narrows the list to keys the provider confirms still work. Cost: the tools are free. Rotating a key takes a few minutes. Rewriting history to remove it takes about an hour, and rotation matters more, because a key in a public commit has already been copied.
Check 2: Did a secret ship to the browser?
npm run build && grep -rIoE \
-e 'sb_secret_[A-Za-z0-9_-]+' -e 'sk_(live|test)_[A-Za-z0-9]+' \
-e 'sk-[A-Za-z0-9_-]{20,}' -e 'postgres(ql)?://[^:/]+:[^@]+@' \
.next/static | sort -u Fails when: anything prints. Next.js copies every variable that starts with NEXT_PUBLIC_ into the browser bundle at build time, as plain text. So when someone fixes an “undefined” error by adding the prefix to STRIPE_SECRET_KEY, the key becomes public. The patterns cover Supabase secret keys, Stripe and Clerk live and test keys, OpenAI keys, and database URLs with a password in them. On Vite the folder is dist/assets and only VITE_ values are inlined. Cost: free. Rotate the key and move the code that needed it into a server route, about an hour.
Check 3: Which Supabase key did you ship?
grep -rhoE 'sb_(publishable|secret)_[A-Za-z0-9_-]+' .next/static | sort -u
grep -rhoE 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}' .next/static \
| sort -u
# decode the middle part of each token the second grep printed
node -e 'console.log(Buffer.from(process.argv[1].split(".")[1],
"base64url").toString())' eyJhbGciOi... Fails when: the first grep finds an sb_secret_ key, or the decoded payload of any token in the bundle says "role":"service_role". Supabase has two kinds of key. The publishable key (formerly called anon) is meant for browsers and is limited by row-level security. The secret key (formerly service_role) ignores row-level security entirely, so shipping it hands out your whole database. The older keys are tokens (JWTs): the second grep lists any in the bundle, and the node line decodes the middle part of one so you can read its role. Cost: free. Rotate it in the dashboard and redeploy, about half an hour.
It is cheaper to stop a key from being written than to rotate it later. If you use Claude Code, a ten-line hook refuses edits to .env before they happen.
The Database: Is It Actually Closed? (Checks 4–5)
In a Supabase app the browser talks to the database directly, so the database has to decide who may read which row. That job belongs to row-level security (RLS), and it is off by default on every table you create in SQL. An open table is what the 57% in the opening scan measured.
Check 4: Which tables have RLS off?
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename; Fails when: any row shows rowsecurity = f. That table can be read and written by anyone who holds your publishable key, which is everyone, because the key is in the browser bundle. Then open Security Advisor in the Supabase dashboard and act on 0013_rls_disabled_in_public, 0010_security_definer_view, 0002_auth_users_exposed and 0024_permissive_rls_policy (a policy that says USING (true)) first. Cost: free. Writing policies takes ten to thirty minutes per table once you have done the first one.
Check 5: Prove it from outside
curl "https://abcdefghijkl.supabase.co/rest/v1/orders?select=*&limit=5" \
-H "apikey: sb_publishable_AbC123..." Fails when: real rows print. Your project URL and publishable key are under Settings, then API, in the Supabase dashboard, and the table is any of yours. An empty array on a table you know has rows means RLS is working, and an error object with code 42501 means the anonymous role has no access to that table at all, which is also fine. Repeat for every table that holds user data; this is the same request that found Moltbook’s database and the open databases in the Reeve scan. Cost: the fix is the same as check 4.
If you use Prisma behind your own API routes, RLS does not apply and these two checks pass on their own. Your version is check 7. The Lovable-specific failures, including a disputed 2025 vulnerability report (a CVE), are covered in where Lovable hits the wall.
Authorization: Who Owns the Record? (Checks 6–7)
Broken access control is the top item on the OWASP Top 10:2025. In plain terms, the app checks that you are logged in and then hands you a record without checking that it is yours. That was the bug in both Lovable incidents this year.
| Provider | Auth call in a route handler | Session cookie |
|---|---|---|
| Clerk | auth() or auth.protect() | __session, expires about 60 s after you copy it |
| Auth.js | auth() | __Secure-authjs.session-token |
| Better Auth | auth.api.getSession() | __Secure-better-auth.session_token |
| Supabase | supabase.auth.getClaims() or getUser() | sb-PROJECTREF-auth-token |
Check 6: How many route handlers never check auth?
find app src/app -name 'route.*' 2>/dev/null | wc -l
grep -rlE 'auth\(\)|protect\(|getSession\(|getClaims\(|getUser\(' \
--include='route.*' app src/app 2>/dev/null | wc -l Only for apps with their own API routes. If your app talks to Supabase directly, its authorization is RLS, and checks 4, 5 and 7 cover it. Fails when: the second number is smaller than the first by more than your auth provider’s own catch-all route and your webhook receivers, which verify a signature instead. The table above names the auth call for each provider. On Clerk, clerkMiddleware() protects nothing by itself; the docs say middleware “is not the best place to protect routes” and to check access “as close to the resource as possible, in the code that reads or mutates the data.” Cost: free, five to fifteen minutes per route.
Check 7: Can user A read user B’s record?
The plain version needs no terminal. Sign in as user A and open something that belongs to you: an order, a document, a settings page. Then change the id in the address bar to one that belongs to user B, a record you created with your second test account. If the page loads, you failed. The commands below are the same test from a terminal. For a Supabase app, run the check 5 request again with user A’s access token, which you find in DevTools under Application, Local Storage, the sb-…-auth-token entry. For an app with its own API, open Network in DevTools, right-click the request that loaded your record, choose Copy as cURL, swap the id for user B’s, and add -si | head -1 to print only the status line.
# Supabase apps: the same request as check 5, signed in as user A
curl "https://abcdefghijkl.supabase.co/rest/v1/orders?select=*" \
-H "apikey: sb_publishable_AbC123..." \
-H "Authorization: Bearer eyJhbGciOi..."
# own-API apps: the request DevTools copied, with the id swapped for user B's
curl -si 'https://app.example.com/api/orders/9f2c' \
-H 'cookie: __session=eyJhbGciOi...' | head -1 Fails when: the Supabase request prints rows that belong to other users, or the API request comes back 200 for user B’s id. You want an empty array, a 403 or a 404. Run the API version once with your own id first: it must return 200, or the cookie is not being sent, and a request that always fails looks exactly like a pass. Clerk’s cookie expires about 60 seconds after you copy it; the cookie name for each provider is in the table above. Cost: free. Add a condition to each query so it also matches the caller’s organization, for example where: { id, organizationId }. That is twenty to sixty minutes across a small app, and it is the fix AI tools skip most often, because nothing visibly breaks when it is missing.
Dependencies: Known Holes and Fake Packages (Checks 8–9)
Your package.json can hold two kinds of problem: packages with known vulnerabilities, and packages that were never real.
Check 8: Known vulnerabilities in what you ship
npm audit --audit-level=high --omit=dev
osv-scanner scan source -r . Fails when: the exit code is non-zero and the table shows high or critical rows. The --omit=dev flag (the current spelling of --production) keeps build-tool noise out of the report. The other package managers have the same audit, in the table below; yarn needs -R or it audits direct dependencies only. The osv-scanner line catches what npm audit cannot see: packages from other ecosystems and advisories npm has not mirrored. Cost: free, ten to sixty minutes. Run npm audit fix first. Treat --force as a major version bump, because that is what it does.
| Package manager | Same audit |
|---|---|
| pnpm | pnpm audit --audit-level high --prod |
| yarn | yarn npm audit --severity high --environment production -R |
| bun | bun audit --audit-level=high --prod |
Check 9: Is every dependency a real package?
node -p "Object.keys(require('./package.json').dependencies).join('\n')" \
| while read -r p; do printf '%-40s ' "$p"; npm view "$p" time.created; done
npm query ':attr(scripts,[postinstall]), :attr(scripts,[preinstall])' \
| grep '"name":' Fails when: a dependency was created only weeks ago, has one or two versions, and lists no repository. Or when the second command names a package you do not recognize, because a script that runs at install time is how a fake package gets to run code on your machine. pnpm 10 and bun block install scripts by default; pnpm ignored-builds and bun’s trustedDependencies list are the allow-lists to check.
AI models invent package names, and they invent the same wrong names again and again: a USENIX Security 2025 study found that commercial models invented a package name about one time in twenty, and open-source models about one time in five (Spracklen et al., 2025). That was measured on 2024-era models and has not been repeated. Attackers register those invented names and wait, a move called slopsquatting: in February 2026 npm put unused-imports, a name models produce when they mean eslint-plugin-unused-imports, under a security hold after people had been installing it (Aikido, February 2026). Cost: free. A few minutes per suspect package, and npm ci --ignore-scripts in CI so the next fake one cannot run on install.
Rate Limits, Headers, and a Code Scan (Checks 10–12)
The next two checks need no account, which is why an attacker tries them first. The last one is the code scanner.
Check 10: Is anything rate-limited?
for i in $(seq 1 40); do
curl -s -o /dev/null -w "%{http_code} " \
'https://app.example.com/api/generate' \
-H 'content-type: application/json' -d '{"prompt":"hi"}'
done; echo Fails when: all forty responses come back and none of them is a 429, the “too many requests” status. Click the expensive button in your app (generate, send, export), find that request in DevTools under Network, Copy as cURL, and paste it in place of the curl inside the loop; the -w flag prints only each status code. Run it against a preview deployment, never production. Sign-in is usually limited by the provider already (Clerk hosts it on its own servers, so a Clerk app has no sign-in route to test); Auth.js is the exception, because it ships no limiter. Cost: a free Upstash tier or your host’s firewall rules, and thirty to sixty minutes.
Check 11: What else is your host serving?
for p in /.env /.env.local /.git/config; do
printf '%s ' "$p"
curl -s -o /dev/null -w "%{http_code}\n" "https://yourapp.com$p"
done
curl -sI https://yourapp.com | grep -iE \
-e 'strict-transport-security|content-security-policy' \
-e 'x-frame-options|x-content-type-options'
find .next/static -name '*.map' | head Fails when: a dotfile returns 200, the header grep prints nothing, or the source-map listing shows files. The dotfile probes only matter if you host the app yourself with the repo as the web root. Vercel adds the HSTS header itself, so the other three prove your headers() block is live. A source map hands over your unminified code; about one vibe-coded app in eight in the Reeve scan served them. Cost: free. Twenty to forty minutes for a headers() block in next.config.js and productionBrowserSourceMaps: false.
Check 12: Run a code scanner
semgrep scan --config p/owasp-top-ten --config p/secrets --error . Fails when: it exits 1 with any finding. The p/owasp-top-ten ruleset follows the same categories this post is organized around, and p/secrets overlaps check 1 for anything still in the working tree. Do not add a severity filter; most of the JavaScript rules in the OWASP set are warnings. Cost: free. Budget one to four hours to go through the first run. After that it is a CI step that only speaks up about new code.
The Whole Checklist on One Screen
Bookmark this table. It lists the same twelve checks with the failing result and the cost side by side.
| Check | Fails when | Cost to fix |
|---|---|---|
| 1 Git history | gitleaks reports a finding; trufflehog exits 183 | Rotate: 5–30 min per key |
| 2 Bundle grep | Any secret pattern in .next/static | Rotate + server route: 30–90 min |
| 3 Key class | sb_secret_ or role service_role in the client | Rotate + redeploy: 30 min |
| 4 RLS query | Any public table with rowsecurity = f | Policies: 10–30 min per table |
| 5 Open database | Real rows returned while signed out | Same as check 4 |
| 6 Auth count | Fewer auth calls than route handlers | 5–15 min per route |
| 7 Wrong-user test | 200 on another user’s record | Ownership checks: 20–60 min |
| 8 npm audit | Non-zero exit with high or critical rows | 10–60 min; fix before force |
| 9 Fake package | A dependency days old, or an unknown postinstall | 10 min to remove; --ignore-scripts in CI |
| 10 Rate limit | 40 requests, not one 429 | 30–60 min on a free tier |
| 11 Edge | A dotfile returns 200, no security headers, or .map files | 20–40 min in next.config.js |
| 12 Semgrep | Exit 1 with any finding | 1–4 hrs first triage, then CI |
Let Your Coding Agent Run It
You do not have to run these by hand. Ten of the twelve are shell commands, so a coding agent with a terminal (Claude Code, Cursor, Codex) can run them and hand you a report. Check 4 works through the official Supabase MCP server, whose get_advisors tool returns the Security Advisor findings. Check 7 needs two test accounts you create first. Check 10 stays with you, because nobody should let an agent choose which URL to hit forty times.
Paste this into the agent from the repo root, with a preview URL, and read the report before you fix anything:
~/my-app $ claude
> Run the twelve checks from
https://vibeready.sh/blog/vibe-coding-security-checklist/ against this repo
and the preview deployment at <preview-url>. Rules: do not edit, create or
delete any file; report only. For check 7 use these two test accounts:
<user-a-email> and <user-b-email>. Skip check 10 unless I say otherwise.
For check 4 use the Supabase MCP get_advisors tool if it is available.
Return one table: check, pass or fail, the exact evidence, the fix.
Two rules make it safe. Give the agent a preview URL, never production, because check 7 reads real rows and check 10 writes forty of them. And keep the agent read-only, because an agent that can edit will sometimes “fix” a finding by weakening the check; in Claude Code that means a subagent with Bash, Read and Grep and no Edit.
Put the Checks in CI
Running the checklist once catches today’s problems. Putting the same checks in CI means they also run on every commit the AI makes next month.
Three of the twelve belong in CI now: gitleaks on every push, and npm audit and Semgrep on every pull request, with npm ci --ignore-scripts as the install step so check 9 cannot bite you later. The four live checks (5, 7, 10 and 11) take about twenty minutes a month by hand, because they catch configuration changes that never went through a commit.
The “never” rules in your AGENTS.md (never disable RLS, never hardcode a secret, never skip the ownership check) reduce how many findings you get. A harness, the checks wrapped around the model, makes sure the gate runs whether or not the model remembered the rule. A vibe-coded app at scale needs both.
In our own kit that looks like this: a security-reviewer subagent reads every diff against an OWASP checklist before it merges, and every API route ships with a test that calls it as a member of the wrong organization. Check 7 runs on every change without anyone having to remember it.
If you’d rather start from a Next.js foundation where the auth, organization scoping, and review gates above are already wired and tested, that’s the VibeReady starter kit. See editions from $99 →
Frequently Asked Questions
Is AI-generated code less secure than code people write?
Not by much, in real repositories. A March 2026 study of AI-written code in public repos found it set off fewer scanner warnings and contained fewer hardcoded secrets than human-written code. The breaches come from defaults: open databases, keys shipped to the browser, and routes that never check ownership.
Do I need row-level security if I use Prisma and my own API routes?
No. RLS is the database enforcing per-row access, which matters when the browser queries the database directly, as Supabase apps do. Behind your own API the equivalent is scoping every query by organization and testing each route with another tenant's id, which is check 7.
How often should you run a vibe coding security checklist?
At three moments: before the first real signup, before you take a payment, and monthly after launch. Put the repository checks (secrets, dependencies, Semgrep) in CI so they run on every change. The live-app checks take about twenty minutes a month by hand.
Is npm audit enough to catch vulnerable dependencies?
It catches known advisories in npm packages and misses two things: transitive vulnerabilities in other ecosystems, and packages that were never legitimate. Pair it with osv-scanner for coverage and with a creation-date check for hallucinated package names.
What is slopsquatting?
An attacker registers a package name that AI models invent when they mean a real one, so whoever pastes the generated install command downloads the attacker's code. In February 2026 npm put unused-imports, a hallucinated stand-in for eslint-plugin-unused-imports, under a security hold.
Have more questions? See our full FAQ →