Peakstack
Contents
Part III. What actually breaks16 min read

Where vibe-coded apps actually break: a field atlas

Ten failures we find over and over in real, shipped, AI-built applications. Almost none of them break your demo. That is precisely why they are still there.

The organising fact of this chapter: every failure below is invisible from your own browser. You are signed in. You click in the order you designed. Nothing has gone wrong yet. From where you are sitting, the app is perfect, and it will keep looking perfect right up until the moment someone who is not you arrives and does something you did not anticipate.

The fatal five

These end companies. Not “cause an incident.” End them. Every one of them lets a stranger read or change data that is not theirs, and every one of them is a fifteen-minute fix if you find it before they do.

1

A private API key is sitting in your JavaScript bundle

Fatal
What it looks like
Everything works. Beautifully. Your OpenAI calls succeed, your emails send, your Stripe charges go through, because the key is right there in the code the browser downloaded, and anyone can read it.
Why the model let it happen
The code that needed the key was running in the browser, so the model put the key where that code could reach it. It solved the problem it was given. Nobody asked it to consider that “the browser” means every visitor’s computer, and that shipping code to a browser means shipping it to the public.
What it costs you
A stranger runs your OpenAI key until the bill hits five figures. Or sends email as you. Or reads your Stripe customer list. Bots scrape public bundles for exactly this, automatically and continuously. You do not need to be famous to be found.
app/page.tsxVulnerable
const openai = new OpenAI({
  apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
  dangerouslyAllowBrowser: true,   // the library is literally telling you
});

NEXT_PUBLIC_ is not a naming convention. It is an instruction to the build system: copy this value into the JavaScript you send to the public. It does exactly that.

Full treatment, including how to check your own bundle in ninety seconds: API keys, env vars, and what NEXT_PUBLIC_ really means.

2

Your database is readable by anyone who asks it politely

Fatal
What it looks like
Nothing. It looks like a working app. Your Firestore rules say “allow read, write: if true”, or your Supabase table never had RLS switched on, and the entire internet can query it directly, without an account, without your frontend, without you ever knowing.
Why the model let it happen
Restrictive rules would have broken the app during development, constantly, with confusing permission errors. The permissive rule makes every feature work immediately. The model chose the version that does not generate errors, and the tutorial it learned from started with the same line.
What it costs you
Total. Every user record, every private message, every email address, downloadable by a stranger with a browser console. This is the single most common serious finding in AI-built apps, and it is the one that makes the news.
firestore.rules: the most expensive four words in vibe codingVulnerable
match /{document=**} {
  allow read, write: if true;
}

The fix, in both Firebase and Supabase: Auth, roles, and database rules.

3

Your permission checks only exist in the user interface

Fatal
What it looks like
The Admin button only renders for admins. The Delete button only appears on your own posts. It all behaves correctly, as long as everyone interacts with your app by clicking on it.
Why the model let it happen
You asked for admin-only features. The place the model could see the concept of an admin was the component that renders the button, so that is where it put the check. It is not wrong about the UI. It simply has no reason to assume anyone would bypass the UI.
What it costs you
Hiding a button does not remove the endpoint behind it. Anyone can open the network tab, watch what your Delete button sends, and send it again with a different ID. The check was decoration; the door was never locked.
components/AdminPanel.tsxVulnerable
{user.role === "admin" && <DeleteAllButton />}   // cosmetic only

This is a CSS-level security model. The API route it calls will happily serve anyone who calls it directly. The button was never the thing standing in the way.

4

Your app believes whatever localStorage tells it about who you are

Fatal
What it looks like
Login works. Refresh keeps you signed in. The user's name and role come back correctly every time. Under the hood, the app reads its answer to “who is this?” out of browser storage, which is a place the user controls completely.
Why the model let it happen
It needed to persist the session across a refresh, and localStorage is the simplest thing that does that. Real session handling means httpOnly cookies or a verified token, which is more machinery for the same visible result: you stay logged in.
What it costs you
Anyone can open DevTools and edit their own identity. Type it, refresh, and be an administrator. There is no exploit here and no tooling required. It is a text field, and it is editable because it was always the user's to edit.
lib/auth.tsVulnerable
// Anyone can run this in their console and become an admin:
//   localStorage.setItem("user", '{"id":"1","role":"admin"}')
const user = JSON.parse(localStorage.getItem("user") ?? "{}");
if (user.role === "admin") showAdminDashboard();

The user can set this. It is their browser. Identity has to come from a token your server verifies, never from a value the client can write.

5

Users can write their own role, credits, or balance

Fatal
What it looks like
A clean user document with fields like role, credits, isPro, plan. Your app updates them when someone upgrades. Your database rules let the user write to their own document, which sounds obviously correct, and is the whole problem.
Why the model let it happen
“Users should be able to update their own profile” is a completely reasonable rule, and the model wrote it. It has no way to know that credits is different in kind from displayName. They are both just fields on a document that belongs to that user.
What it costs you
Free plan forever. Infinite credits. Self-promotion to admin. One line in the console. Every paid tier you built is now optional, and your revenue quietly stops matching your usage.
firestore.rulesVulnerable
match /users/{userId} {
  // Looks right. Lets the user set their own role and credits.
  allow write: if request.auth.uid == userId;
}

A user's own document includes the fields that decide what they are allowed to do. Privileged fields must be server-writable only, unless the rule explicitly forbids those keys.

The expensive three

These do not hand over your database. They hand over your money, your reputation, or your users’ trust, which are harder to get back than a rotated key.

6

Your AI feature is an uncapped credit card pointed at the internet

Expensive
What it looks like
A chat box, a generator, a summariser. It calls a model on the backend, correctly and securely. There is no rate limit, no per-user cap, no spend ceiling, and no reason a single visitor cannot call it ten thousand times.
Why the model let it happen
You asked for a feature that works. Rate limiting is not part of a feature working. It is part of a feature surviving. It never comes up unless you raise it, because in development there is exactly one user and they are you.
What it costs you
A script hits your endpoint in a loop overnight. You are paying per token for every call. People have woken up to four-figure bills from a single afternoon of this, and the provider is not going to refund it.
7

Your landing page promises things your app does not do

Expensive
What it looks like
The marketing copy says “export to PDF”, “team collaboration”, “real-time sync”. Two of those work. One is a button that opens a modal saying “coming soon”, and one is a button that does nothing at all, silently.
Why the model let it happen
You wrote the landing page while you were excited about the plan, and the plan drifted. The model built what you asked for last, not what you advertised first. Nothing in your toolchain compares those two documents, because nothing in any toolchain does.
What it costs you
Every visitor who signed up for the feature you don't have is a refund, a bad review, or a chargeback. It is also, at a certain scale, false advertising, and it is the most common gap we find between what a site claims and what its app actually does.
8

Private data is reachable even though the UI hides it

Expensive
What it looks like
Your dashboard shows a user only their own orders. Correct filtering, correct query. But the API route takes an ID from the URL and trusts it, so changing /orders/1042 to /orders/1041 in the address bar returns somebody else's order.
Why the model let it happen
The model wrote a route that fetches the record you asked for. Checking that the requesting user is allowed to have that particular record is a second, separate thought, and it is one nobody had, because in testing you only ever requested your own.
What it costs you
A data breach with no hacking involved. Sequential IDs make it trivially enumerable: a fifteen-line script walks your entire orders table. Regulators call this a reportable incident, and they do not accept “the button wasn't there” as a mitigation.
app/api/orders/[id]/route.tsFixed
const order = await db.order.findFirst({
  where: {
    id: params.id,
    userId: session.user.id,   // the clause that closes the hole
  },
});
if (!order) return new Response("Not found", { status: 404 });

The fix is one clause. Never fetch by ID alone. Fetch by ID and owner, so an unauthorised request returns nothing rather than someone else's data.

The embarrassing two

Nobody gets breached. You just look like you are not ready, in front of the exact audience you were trying to impress.

9

Placeholder data made it to production

Embarrassing
What it looks like
Three testimonials from people who do not exist. A stats bar reading “10,000+ happy users” on launch day. A pricing page with Lorem ipsum in the fine print. An avatar row of stock photos.
Why the model let it happen
The model filled in realistic-looking sample content so the page would not look broken while you built it, which was genuinely helpful. It then had no mechanism to remind you it was fake, because to the code it is just a string.
What it costs you
The first person to notice will be someone deciding whether to trust you with a credit card. Fake testimonials are the fastest way to convert “interesting product” into “this is a scam” in a single scroll.
10

Nothing has ever failed, so nothing handles failure

Embarrassing
What it looks like
The app is flawless until the network hiccups. Then: a spinner that spins forever, a blank white screen, or a raw stack trace printed on the page. No error message, no retry, no way back.
Why the model let it happen
You never tested the sad path, so the model never wrote one. Every request in development succeeded, on localhost, in milliseconds. The failure branch is code nobody has ever needed, which means nobody ever wrote it.
What it costs you
Users do not report this. They leave, and you never learn why. Your analytics show a drop-off at a step that works perfectly every time you try it, because you have fast wifi and a valid session.

The pattern behind all ten

Read the “why it happened” lines back to back and one shape emerges. In every case, the model optimised for the app working, for you, right now. Open the database so nothing errors. Put the key where the code needs it. Check the role where the button is. Trust the ID that was passed in. Fill the page with plausible content. Skip the branch that has never been taken.

Not one of those decisions is stupid. Each is locally optimal for the goal it was given. They are only catastrophic in aggregate, and only once the app meets someone whose goal is different from yours.

Which gives you the correct mental model for the whole practice: the model builds for the user you described. Your job is to defend against the user you did not.

How to actually find these in your own app

Reading a list is not finding them. These failures are specifically the ones that do not show up from where you are standing, so checking requires you to stand somewhere else: a browser with no session, no cookies, no privileges, poking at the app the way a stranger would.

You can do a meaningful amount of this by hand, and you should:

  • Open your site in a private window, open DevTools, go to Sources, and search your JavaScript for sk-, secret, service_role, and password.
  • Sign out. Then hit your own API routes directly. Do they answer?
  • Change an ID in a URL to one you do not own. Does data come back?
  • Open your Firestore rules or Supabase RLS settings and read them aloud. If you cannot say who is denied, nobody is.

The pre-launch checklist turns all of that into thirty-two checks you can actually perform, in order, before you post the link.

When you’re ready to ship

Get an invite to PeakStack.

These ten are what we look for, every time, because they're what we keep finding. The catch is that you cannot check them from inside your own session: every one of them is invisible to a logged-in owner clicking in the expected order. PeakStack drives your live app in a real browser, checks what your landing page promises against what the app actually does, and runs the security pass this chapter describes: exposed keys, open database rules, client-side admin gates. You get a letter grade, ranked fixes, and an honest list of what we couldn’t check.

Private beta. Join the waitlist for an invite.