The 7 security holes in every vibe-coded app
The feature work is fine. The security is almost always the same seven holes — and any one can end a company on launch day.
I tear down a lot of apps that founders built with AI in a weekend. The feature work is often genuinely good. The security is almost always the same seven holes — and any one of them can end a company on launch day.
This isn't a knock on AI coding. It's a knock on shipping what it gives you without a second pass. The model optimises for “make the feature work,” not “make it safe when a stranger pokes at it.” Here's what the data says, then the seven holes I find almost every time — each with the exact fix.
01Secrets shipped to the browser
The number one finding, every time. A secret key — a service token, an admin key, a database URL — sits in the frontend bundle. AI tools love putting things in NEXT_PUBLIC_variables because it “just works” — but anything prefixed that way is shipped to every visitor's browser. (Publishable / anon keys are meant to be public — it's the secret ones that end careers.) I find these in under four minutes with the Network tab open.
1// .env.local-NEXT_PUBLIC_OPENAI_KEY=sk-proj-...-NEXT_PUBLIC_STRIPE_SECRET=sk_live_...4 5// client component6const res = await openai.chat(...)1// .env.local (no NEXT_PUBLIC prefix)+OPENAI_KEY=sk-proj-...3 4// app/api/chat/route.js — server only+const res = await openai.chat(...)6// browser calls /api/chat, never sees the key02Authorisation that only exists in the UI
The app hides the “Delete” button unless you're an admin. But the API endpoint behind it never checks who is calling. Hiding a button is not security — anyone can call the endpoint directly with curl. The check has to live on the server, on every request.
1export async function DELETE(req) {- // no check — UI 'hides' this3 await db.users.delete(req.body.id)4}1export async function DELETE(req) {+ const me = await getSession(req)+ if (me?.role !== 'admin')+ return res(403)5 await db.users.delete(req.body.id)6}03Supabase / Firebase left unprotected
Managed databases hand your frontend a public key — Supabase's anon key, Firebase's config — that's meantto ship in browser code. It's safe only if you've turned on access rules that say who can read what.
The trap is real. In Supabase, tables created via raw SQL have Row Level Security off, and a table is live to the public API the moment it exists — so the public key reads every row. Firebase has its own version: pick test mode to move fast and the database is open to the world for 30 days. Miss either, and anyone with the public key reads every user and every record.
04No rate limiting anywhere
Generated code rarely adds rate limits. On a login route that means unlimited password guesses. On anything wrapping a paid API — AI, email, SMS — it means one script can run your bill to four figures overnight.
1// a basic limiter is 6 lines and stops the worst abuse2const hits = new Map()3export function rateLimit(ip, max = 10, windowMs = 60_000) {4 const now = Date.now()5 const log = (hits.get(ip) || []).filter(t => now - t < windowMs)6 log.push(now); hits.set(ip, log)7 return log.length <= max8}05Trusting whatever the client sends
Two classics. IDOR: the endpoint takes anid from the request and returns that record without checking you own it — change the number, read someone else's data. Mass assignment: you spread the whole request body into a database write, so a user can send role: "admin"and promote themselves.
1await db.users.update(id, {- ...req.body // role, isPro, anything3})+const { name, bio } = req.body2await db.users.update(id, {+ name, bio // only what's allowed4})06Error messages that hand over the map
Unhandled errors return full stack traces, SQL strings, and file paths to the client. Each one tells an attacker exactly what stack you run and where to push. Catch errors, log the detail server-side, return a generic message.
07API routes with no authentication at all
The frontend is behind a login, so it feels protected — but the API routes underneath often have zero auth. They're reachable directly, no session required. Every route that touches data needs to verify the session itself, not assume the UI gated it.
The browser is hostile territory. Anything it can see is public, and anything it can send, it will lie about. Security lives on the server or it doesn't exist.
— The rule I give every founder I tear down
- ✓No secret keys in the frontend — grep your bundle for sk_, service_role, and NEXT_PUBLIC_
- ✓Every data endpoint checks the session on the server, not the UI
- ✓Row Level Security on, with a policy per table
- ✓Rate limits on auth and any paid-API route
- ✓Allow-list fields on writes; verify ownership on reads
- ✓Generic error messages to the client, full detail in your logs only
None of this slows you down once it's habit — it's an afternoon, not a rebuild. And it's the difference between a launch and a breach post on the day you wanted to celebrate.
We'll find your biggest launch risk. Free. In 48 hours.
Send your app — get a 3–5 minute video from a senior founder-engineer showing the one thing most likely to break trust, and how to fix it.
Get a free teardown →