Three weeks before a launch, I ran a review over a product that had been built almost entirely in Cursor across two months. I was not worried. The app worked. Every flow had been clicked through by hand: sign up, connect account, run the core feature. Nothing was broken.
The review returned a critical authentication bypass. Anyone who knew the shape of the request could read any other user's account. The AI had written it cleanly, confidently, and completely wrong, and it had been sitting in the codebase for six weeks without a single test failing.
That gap between *works* and *correct* is the entire subject of this post.
What is vibe coding?
Vibe coding is building software by describing what you want in natural language and letting an AI write the implementation. You hold the product in your head; the model holds the syntax. Tools like Cursor, GitHub Copilot, Claude Code, Bolt, and Lovable have made this workable for millions of people who would never have shipped software otherwise.
The term was popularised by Andrej Karpathy in early 2025 to describe a mode of working where you stop reading every diff and start trusting the model's output the way you'd trust a compiler. The productivity gain is real and it is very large. A non-technical founder can now put a working SaaS product in front of users in a weekend.
The part that gets discussed less: you have also accepted every assumption the model made on your behalf, and you have no record of what those assumptions were.
Why does AI-generated code break in production?
The model is not making mistakes the way a tired engineer makes mistakes. It is producing code that matches the *pattern* of correct code without necessarily having the *properties* of correct code. Those are different things, and the difference is invisible on a screen.
Consider what these models learned from. The training distribution skews heavily toward tutorial code written to demonstrate one concept, open-source examples optimised for readability, and Stack Overflow answers optimised for brevity. In all three, error handling is trimmed for clarity, authorisation is assumed to be handled elsewhere, and the adversarial case is out of scope. Security hardening is systematically underrepresented in exactly the corpus the model is imitating.
The AI had written it cleanly, confidently, and completely wrong. Nothing in the file looked out of place, which is precisely why nobody caught it.
So you get output that implements the happy path correctly and handles the adversarial path incorrectly. Reliably. Every time. That reliability is actually good news: predictable failures are findable failures.
The four failure classes that account for most incidents
1. Silent failures
Your app calls an external API. At 02:00 the API returns a 500. The generated code continues as though the call succeeded. No throw, no log, no alert. Users see empty or wrong data and you find out from a support ticket four days later.
// Generated: happy path only
const res = await fetch(url)
const data = await res.json() // res.ok is never checked
return data.items // undefined on a 500 → silent empty state
// What it needs
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new UpstreamError(res.status)
const data = await res.json()
return data.items ?? []2. Authentication that authenticates but does not authorise
This is the single most common critical finding in AI-generated backends. The endpoint correctly verifies *that you are logged in* and then never verifies *that the thing you asked for belongs to you*. Change an ID in the URL and you read someone else's record. It is catalogued as IDOR, and it accounts for a large share of real-world data exposure.
// Generated: any logged-in user can read any document
router.get('/api/documents/:id', authenticate, async (req, res) => {
const doc = await Document.findById(req.params.id)
return res.json(doc)
})
// Ownership enforced in the query itself
router.get('/api/documents/:id', authenticate, async (req, res) => {
const doc = await Document.findOne({
_id: req.params.id,
userId: req.user.id, // never from the request body or params
})
if (!doc) return res.status(404).json({ error: 'Not found' })
return res.json(doc)
})3. Business-logic drift
You described your pricing in a sentence. The model implemented the average of every pricing system in its training data. Those are close, and close is worse than wrong. A wrong implementation fails loudly in testing, a nearly-right one under-charges every customer by a small amount that compounds silently for months.
This is the failure class that static analysers structurally cannot catch, because there is no bug in the code. The syntax is valid, the types check, the tests pass. It computes the wrong number correctly. The only way to catch it is to give the reviewer your actual business rules and have it check the implementation against them.
4. N+1 database queries
The model writes a loop and queries inside the loop. With the ten rows in your seed data it is instant. With ten thousand rows in production it opens ten thousand connections and the database stops answering. This is the most common cause of an app that was fine in staging and fell over on launch day.
Why does normal testing miss all four?
Because testing checks whether the thing you built does what you intended. All four failures above occur when something happens that you did not intend and therefore did not write a test for: an upstream service failing, a user modifying a request, a rule being subtly misread, a table getting large.
| Failure | Passes tests? | Visible in demo? | Cost when found in production |
|---|---|---|---|
| Silent API failure | Yes | No | Corrupted data, discovered late |
| Missing authorisation | Yes | No | Data breach, disclosure obligations |
| Business-logic drift | Yes | No | Revenue leakage, refunds, trust |
| N+1 queries | Yes | No | Outage under first real load |
Every row is the same shape: passes, invisible, expensive. That is the signature of a problem that needs a different detection method rather than more of the same testing.
So should you stop vibe coding?
No. The velocity is real and giving it up is a bad trade. Going back to hand-writing every line to avoid four known failure classes is like refusing to drive because seatbelts exist.
What is missing is the step between generation and production. You already have generation covered by tools that are extremely good at it. What you need is something that reads the output with the assumption that it is wrong, checks the specific things models reliably get wrong, and tells you in plain language what it found. That is a thirty-second step, and it is the difference between finding the authorisation bug yourself and having a stranger find it for you.
If you want the concrete version of that step, the five-point pre-deployment checklist covers exactly what to check and in what order. If your product handles payments or user records and you do not have an engineering background, start with code review for non-technical founders instead.