RtK

7 Ways Vibe-Coded SaaS Breaks in Production and the Minimal Fix for Each

June 24, 20264 min read

'It compiles' is not a security policy. The seven failure modes we see in every AI-built SaaS rescue, each with a public receipt and the smallest fix that closes it.

The 7 ways AI-built SaaS breaks under real users — client-trusting endpoints, missing authz, leaked secrets, silent fail
On this page

If you're running a business, "it compiles" is not a security policy. Vibe coding is great for proving a product has market interest. Shipping prototype-grade logic to paying users is how you lose database integrity, customer trust, and MRR. These are the seven failure modes we find in nearly every AI-built SaaS we rescue — what each looks like, why the model produced it, the smallest fix, and the public receipt.

1. Endpoints trust the client

A profile-update endpoint takes the JSON body and writes it straight to the DB. An attacker adds `"role": "admin"` to the payload, and because nothing validates the shape, the write goes through. The model assumes the only thing hitting your API is your own frontend — it's never met curl or Postman. Fix it with a strict schema at the boundary:

typescript
import { z } from 'zod';

const profileUpdateSchema = z
  .object({
    name: z.string().min(1).max(100).trim(),
    email: z.string().email().max(255),
  })
  .strict(); // rejects extra keys like 'role'

app.put('/api/user/profile', async (req, res) => {
  const v = profileUpdateSchema.safeParse(req.body);
  if (!v.success) return res.status(400).json({ error: v.error.issues });
  await db.user.update({ where: { id: req.user.id }, data: v.data });
  res.status(200).json({ success: true });
});

Receipt: Veracode tested 100+ models — 45% of generated code introduces an OWASP Top 10 issue. Founders auditing their own vibe-coded repos report critical vulns in 68% of them, with input validation the most common.

2. Auth exists, authz doesn't

The user logs in, lands on `/projects/123`, changes the URL to `/projects/124`, and reads another customer's data. Classic IDOR. To the model, auth is a solved library drop-in; authorization — which user owns which row — is a system-level relationship it doesn't map. Scope every query to the session user, and 404 (don't 403) so you don't leak existence:

typescript
app.get('/api/projects/:id', async (req, res) => {
  const project = await db.project.findFirst({
    where: { id: req.params.id, ownerId: req.user.id },
  });
  if (!project) return res.status(404).json({ error: 'Project not found' });
  res.json(project);
});

Receipt: CodeRabbit found AI code carries 2.74x more security vulnerabilities than human code; Veracode clocked privilege-escalation paths up 322% in AI-assisted codebases.

3. Secrets in the repo

Your live Stripe key sits in a config file, committed to history because the AI never generated a `.gitignore`. Models copy quickstart docs that hardcode keys to minimize setup friction. Strip them, scan history with gitleaks/trufflehog, rotate anything exposed, and read from the environment with a runtime assertion:

typescript
if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('CRITICAL CONFIG MISSING: STRIPE_SECRET_KEY');
}
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

Receipt: AI-assisted commits leak secrets at 3.2% vs 1.5% for humans (CSA). GitGuardian found 28.65M hardcoded credentials in public commits in 2025 — a 34% jump year over year.

4. Silent failures

The user clicks the main action, the DB write fails, and the UI shows a green checkmark anyway — the error got caught, logged to a stream nobody reads, and a 200 went back. The model wraps async flows in loose try/catch so demos never crash. Map exceptions to real HTTP states and log structured JSON:

typescript
import pino from 'pino';
const logger = pino();

app.post('/api/billing/charge', async (req, res) => {
  try {
    const tx = await processPayment(req.body);
    res.status(200).json({ success: true, transactionId: tx.id });
  } catch (err) {
    logger.error({ err, payload: req.body }, 'Payment processing failure');
    res.status(502).json({ error: 'Payment failed. Please try again.' });
  }
});

Receipt: A Hacker News survey found 43% of AI-generated changes need manual debugging in prod — subtle async/logic bugs that pass unit tests and fail live.

5. No indexes / N+1 queries

Fast at 100 rows, eight seconds at 10,000, DB CPU pinned. The model has never run a query under load — it loops and fires one query per parent row, and never writes the migration to index your foreign keys. Use a join / eager load and add the indexes:

typescript
const orgsWithMembers = await db.organization.findMany({
  include: { members: true }, // single optimized join instead of a loop
});

Receipt: r/SaaS engineering leads keep flagging the same "hollow engineering" — AI code that ignores indexing, pooling, and locking, then falls over at minimal traffic.

6. Dead-code rot

Seventeen versions of a date helper, orphaned interfaces, unreferenced exports. The model writes side-by-side helpers instead of touching code it doesn't fully grasp, which feeds the context decay loop — every duplicate dilutes the AI's context and forces more errors next prompt. Gate it in CI:

bash
npx ts-prune            # find unreferenced exports
# tsconfig.json: "noUnusedLocals": true, "noUnusedParameters": true

Receipt: GitClear: refactoring dropped from 25% of changed lines (2021) to under 10% (2024) while duplicated code quadrupled.

7. Naive billing retries

A card declines on renewal and the webhook either hard-deactivates the account or retries four times instantly and gives up. The model wrote billing as a two-state flow — it doesn't know decline codes, regional banking, or payday cycles. Branch on the decline reason:

typescript
app.post('/webhook/stripe', async (req, res) => {
  const invoice = req.body.data.object;
  if (req.body.type === 'invoice.payment_failed') {
    const code = invoice.last_payment_error?.decline_code;
    if (code === 'insufficient_funds') {
      await scheduleSmartRetry(invoice.subscription, { waitDays: 3 }); // time to payday
    } else {
      await triggerCustomerDunningEmail(invoice.customer); // hard decline -> email
    }
  }
  res.sendStatus(200);
});

Receipt: `insufficient_funds` is ~44% of failed charges; retry within 24h and you recover 45–55%, wait past 8 days and it's under 15%. Naive retries cost 8–12% of MRR in preventable involuntary churn. (This is where the boring layer starts — tenancy, durable jobs, billing recovery.)

Stop generating, start hardening

You don't tear the app down. You verify what the model built: a validation layer, mapped authorization, hardened queries, real error handling, indexes, clean exports, and a dunning engine that respects decline codes. Do that before you scale marketing spend, not after the incident.

That's exactly the work we do — our custom platform team finds, patches, and hardens all seven in place.

Related reading on RtK.Global