The security mistakes I see in every vibe-coded app
Vibe coding works. People are shipping real apps in an afternoon with v0, Lovable, Bolt, Cursor, Claude Code, and friends. The apps look good, the auth flow works, the database is wired up, the Stripe checkout converts. Six months ago this would have taken six weeks.
The same apps also ship with a recurring set of security mistakes I now see on every single one I get pulled into reviewing. None of them are exotic. All of them are the kind of thing the LLM optimized away because the prompt was “make it work,” not “make it safe.”
Here are the patterns. If you’re building this way, audit your app against them tonight.
The service-role key in the client bundle
Supabase, Firebase, Convex, every backend-as-a-service has an “admin” key that bypasses row-level security and lets you do anything. It is meant to live in trusted server contexts only.
LLMs do not always read the docs about this. When you say “this query isn’t returning data,” the model’s path-of-least-resistance fix is often to swap the anon key for the service key, because the service key returns data. The query starts working. The app ships. The key is in your dist/ bundle, readable by anyone with view-source.
How to check: grep your built bundle for the key prefix (eyJ... for Supabase JWT-shaped keys, AIza... for Firebase). If anything matches, your data is open.
Auth state in localStorage with the full user object
The LLM-generated auth flow usually looks like: log in, get user back, stash it in localStorage.setItem('user', JSON.stringify(user)), read it back on page load to “stay logged in.”
This pattern leaks every claim about the user (email, role, plan, internal IDs) into a store any script can read, including any third-party widget you’ll later embed. If any of those fields are used for authorization elsewhere in the app, an attacker who can run JS in your origin can rewrite the role and re-render the admin UI.
The fix is the same as it’s always been: tokens in HttpOnly cookies, the user object refetched server-side per request. The LLM won’t do this by default. You have to ask.
Authorization that lives in the React component
Vibe-coded apps love the pattern where the “is admin” check is {user.role === 'admin' && <AdminPanel />} and there’s no server-side check on the underlying API.
The user opens dev tools, edits state, sees the admin panel. The admin panel makes API calls that the server happily honors because the only authorization was the client-side conditional. The “admin” actions execute.
This one is so consistent I now check for it first. Pop the network tab, hit the API endpoint that powers the admin panel without the conditional. If it returns 200, the app is broken.
Webhooks with no signature verification
Every Stripe, GitHub, Resend, Twilio, Slack integration ships a webhook endpoint. The LLM-generated handler often looks like: receive the JSON, process the event, return 200. No signature verification.
This means anyone can POST a fake “payment succeeded” event to your webhook URL and your app will treat it as real. Free Pro accounts for everyone who can curl.
Every webhook framework provides a one-line signature check (stripe.webhooks.constructEvent, etc.). It’s usually two lines of code. It’s the difference between a working integration and an exploitable one.
CORS set to * because the error was annoying
When the LLM hits a CORS error mid-build, it often resolves it by setting Access-Control-Allow-Origin: * and moving on. This works locally, ships to production, and now any site on the internet can call your API on a user’s behalf if it can phish a session.
The right answer is to allow only the explicit origins you serve. The LLM will do this if you tell it the right origin. It will not do this if you just tell it “fix the CORS error.”
Stripe (or any payment) integration with the price ID in client state
The LLM wires up Stripe checkout by accepting the price ID from the client (/api/checkout?priceId=price_xyz). The user opens dev tools, swaps the price ID for the $0.01 test price they found in your Stripe public dashboard, and pays a cent for the $99 plan.
Always look up the price server-side from a plan ID you control. Never trust a price ID coming from the browser.
Mock auth that ships to production
Placeholder auth used during development (if (password === 'admin') { … }, or a hardcoded list of “test users” with no password check at all) is the most common one. The LLM scaffolded it on day one, intending it to be replaced, and the founder shipped without replacing it because the app “worked.”
Search your codebase for 'admin', 'test', and === in any file containing the word auth. You’ll find more than you want to find.
The one practice that catches most of this
There’s no single tool that catches all of this. There is one habit that catches most: open the browser network tab on your shipped app and pretend you’re hostile.
For each interesting feature, look at the request. Could you call this endpoint without the UI sending you there? Could you change a parameter to do something you shouldn’t? Could you replay the request after logging out? Could a different user’s session call the same endpoint and get data they shouldn’t see?
This is a one-hour exercise. It will surface 80% of the categories above. It’s the audit posture vibe coding most needs and almost never gets, because the founder is moving fast and the LLM is optimizing for “the request returns 200.”
The apps still ship in an afternoon. They just don’t have to be exploitable.