The last 20%: where vibe-coded SaaS apps break and leak
To make a vibe-coded app production-ready you have to survive the invisible 80% — auth that forgets, tenant data that leaks, billing that double-charges. Here's where it breaks, with real incidents, and what "done" actually requires.
To make a vibe-coded app production-ready you don't finish the demo — you survive everything the demo hid. The AI gets you to a working screen at a speed that feels almost unfair. Then the last 20% — real auth, real payments, keeping one customer out of another's data — takes the other 80% of the effort, and it's exactly the part your agent shipped insecure by default. As one builder put it, "that last 20% — the part that makes your SaaS actually ready for paying customers — takes 80% of the effort, and that last 20% is where vibe coding starts to struggle." This post is a map of where it struggles, with the real incidents, and what the boring, dangerous parts actually look like when they're done right.
The demo is a small slice of the real app
The thing you can see and click is roughly 20% of the code a paying customer depends on. The invisible 80% is error handling, idempotency, session lifecycle, input validation, and the wall that stops user A from reading user B's rows. None of it shows up in a screen recording, so none of it gets prompted for. The recurring line across the vibe-coding communities is blunt: "AI does what you ask. It just never thinks about what you didn't ask." (a web developer on r/VibeCodeDevs, 161 upvotes.)
That's not a skill problem you fix with a better prompt. It's a coverage problem. The agent optimizes the visible path because the visible path is what you described. The failure modes live in the paths you didn't think to describe — and those are the ones that leak.
Where it actually breaks, in real incidents
Three categories account for almost every "my vibe-coded app got owned" story. They're worth naming precisely, because each one is a specific, fixable gap — not vague "security."
1. Data that leaks between tenants
A scan of vibe-coded apps found 170+ with fully exposed databases and API keys hardcoded in the browser bundle — because the non-technical builder never knew row-level security existed (vibe-eval.com security report, Feb 2026). This is the most common B2B leak and the most boring: a query that lists the rows instead of this org's rows. It typechecks. It passes review. It ships.
2. Auth that forgets
The bug a blank repo reproduces constantly: "sessions that do not log out on password change. You reset your password, but the old login token still works." Session lifecycle is invisible in the demo, so it never gets built. Revoking a compromised session is a real operation with a real database effect — and if nobody wrote it, it simply doesn't happen.
3. Billing that double-charges
Charging a card on the happy path is a prompt, and it works. A real billing system is the webhook when a subscription lapses, the retry when a payment fails, and the handler that stays idempotent when Stripe sends the same event twice (which it does) so you don't credit the user twice. And when the front end leaks, the blast radius is financial: on Hacker News, "the hacker got a hold of his Stripe key and issued every customer a refund. Now he's hiring a developer to shore it up." (item 44739556).
Notice the pattern: none of the three is exotic. They're the parts that don't render, so they don't get written.
What "production-ready" looks like in code
The difference between a prototype and a base you can charge on isn't more features — it's that these invisible operations exist, are named, and are tested. Here's what that looks like in the useDeploy boilerplate, closing the three gaps above.
Session revocation is a first-class, tested primitive
The blank-repo failure isn't that revocation is buggy — it's that it doesn't exist. Killing a session is a real operation, so it's modeled as one. The infrastructure is three lines and does exactly what it says:
export class PrismaSessionRevoker implements ISessionRevoker {
constructor(private readonly prisma: PrismaClient) {}
async revokeAllForUser(userId: string): Promise<number> {
const result = await this.prisma.session.deleteMany({ where: { userId } });
return result.count;
}
}That primitive is wired into the security flows that need it — force-logout, suspend, soft-delete, and admin-forced password reset — each as an explicit use case with an audit trail:
async execute(input: ForceLogoutUserInput): Promise<Result<{ revoked: number }, DomainError>> {
const target = await this.users.findById(input.targetUserId as UserId);
if (!target || target.deletedAt) return err(new NotFoundError('User not found'));
const revoked = await this.sessions.revokeAllForUser(input.targetUserId);
await this.audit.execute({
action: 'platform.user.force_logout',
actorUserId: input.actorUserId,
resourceType: 'user',
resourceId: input.targetUserId,
payload: { revoked },
});
return ok({ revoked });
}It's idempotent — revoking when no sessions exist is a no-op — and it's unit-tested, asserting the revoke actually fired. The point isn't that a framework silently handles it; it's that "kill the old token" is a real, greppable, tested capability you can attach to whichever flow demands it, instead of an invariant nobody implemented.
The rest of the auth surface is the part you'd otherwise spend a month rebuilding — "building and testing a full featured authentication system takes at least a month to do properly." Passwords hash with argon2id, the OWASP-recommended default, via Bun's native crypto (no npm dependency):
const argon2Hasher = {
hash: (password: string): Promise<string> =>
Bun.password.hash(password, { algorithm: 'argon2id', memoryCost: 19_456, timeCost: 2 }),
verify: (data: { password: string; hash: string }): Promise<boolean> =>
Bun.password.verify(data.password, data.hash),
};On top of that: OAuth, magic-link, TOTP 2FA with backup codes, email verification, and password reset — all wired to BetterAuth and rate-limited per IP and per email. The full surface is on the auth feature page. The relevant fact for a graduating vibe coder is that none of it is yours to get subtly wrong.
Tenant isolation that fails loud instead of leaking
The category-1 leak — a findMany that forgot its where: { organizationId } —
is closed with a Prisma client extension that refuses to run a tenant-scoped read
or mutation unless it proves it's constrained to an org:
query: {
$allModels: {
$allOperations({ model, operation, args, query }) {
assertTenantScoped(model, operation, args);
return query(args);
},
},
},A forgotten filter throws a TenantScopeViolationError on first execution — a
stack trace in your test run — instead of quietly returning another tenant's
rows, which is the version you discover in a support ticket. That's the whole
"fail loud" bet, and it matters double when an agent is writing repositories from
a partial view of the codebase. The full walkthrough — the allowlist, the escape
hatch for legitimate cross-tenant workers, the honest limitations — is in the
tenant-guard post.
Billing that survives the second webhook
Category three is handled by making webhook delivery idempotent and durable: a signed HMAC verification, an outbox that records the event exactly once, and a BullMQ worker that won't re-apply a charge if the provider replays the message. The mechanics — dedup keys, the outbox table, retry semantics — are in the signed-webhooks post. The relevant guarantee: the same event arriving twice doesn't charge the customer twice.
Why a blank repo ships the same three bugs every time
Here's the part that's counterintuitive. These aren't rare bugs your agent got unlucky on. They're the average of what it was trained on. Point Claude Code or Cursor at an empty folder and it averages years of public code — old Stripe examples, App Router and Pages Router mixed together, tutorials that skipped the security step — and the average includes the bugs. So from scratch it reliably reproduces the same three: sessions that survive a password reset, webhooks that aren't idempotent, and RLS gaps that leak data between users.
Starting from a base where those categories are already closed doesn't just save time — it changes what the agent spends its effort on. Instead of re-deriving auth and billing and getting them subtly wrong, it works on your idea, inside walls it can't cross by accident. The compiler and CI enforce the boundary, not a convention the agent can rationalize past.
Graduation, not doom
The honest framing isn't that your prototype is doomed — it's that it's insecure by default, and there's a clean line where you move it somewhere serious. Keep sketching in Lovable, Bolt, or v0; they're genuinely good at that. The moment you have real users, real payments, and auth that gates other people's data is the moment the invisible 80% stops being optional.
That's the whole pitch of the pillar this post sits under: From mockup to production — the boring, dangerous parts already built, locked down, and CI-tested, so the last 20% is something you inherit instead of something that leaks your users onto Hacker News. Point your agent at a foundation, not an empty folder, and let it spend its speed on the 30% that's actually yours.