Skip to content
LUINStart a project

Playbook

Harden a vibe-coded app without throwing the speed away

The demo worked. That is the problem. How AI-generated code fails once users, attackers and invoices arrive, and the order to harden it.

By LUINAI engineering · Security

The invoice list loaded on the first try. You sent the link. By Thursday a customer had every other tenant’s invoices, because the route took orgId from the query string and nobody asked who was allowed to send one. That is the shape of the problem. The product works. “Works” was measured against a demo: one user, one organization, one happy path, no attacker, no invoice, no 3 a.m. People call this vibe coding. You sit with a model, describe the next screen, accept the file, and move on. The loop is conversation, not review. It is a good way to find out whether the product should exist. It is a bad way to decide whether the product should be trusted. The code is not the mistake. Shipping it as if a person had read it is.

The failures are specific

AI-generated code does not fail at random. It fails in the places the demo never visited.

Secrets that travel

The model needs the program to run. The shortest path is a key in the file, or an environment variable that is not secret because it is prefixed for the browser.

const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);

NEXT_PUBLIC_ is not a style choice. Anything with that prefix is bundled into the client. The model saw “environment variable” and stopped. Rotate the key. Then search the history, because deleting the line does not delete the commit. The same shape shows up as a checked-in .env, a service-account JSON left in /secrets “just for local,” and an API key pasted into a prompt that later printed itself in an error toast.

Authorization on the happy path

export async function GET(req: Request) {
  const session = await getSession(req);
  if (!session) {
    return new Response("Unauthorized", { status: 401 });
  }
  const orgId = new URL(req.url).searchParams.get("orgId");
  const invoices = await db.invoice.findMany({ where: { orgId } });
  return Response.json(invoices);
}

This is correct if you trust the query string. You should not. The session check passed. The row still came from whatever orgId the caller sent. Authentication answers who you are. Authorization answers what you may touch. AI is remarkably good at authentication and terrible at authorization. Walk every route that reads or writes a row. If the only check is “a session exists,” that route is public to every logged-in user. An insecure direct object reference is not a clever attack here. It is the default.

Errors that look like success

try {
  await chargeCustomer(order);
} catch (err) {
  console.log(err);
  return { ok: true };
}

The UI shows a confirmation. Finance does not. The model learned that an unhandled exception makes a demo look broken, so it learned to catch everything and keep going. Production needs the opposite: fail in the open, keep the id, refuse to lie.

Migrations that only work on a laptop

ALTER TABLE invoices ADD COLUMN amount_cents integer;
UPDATE invoices SET amount_cents = amount * 100;
ALTER TABLE invoices DROP COLUMN amount;

On a laptop this is instant. On a large production table the UPDATE takes a lock, the DROP deletes the only column you could have rolled back to, and the deploy has already moved on. Expand first, backfill in batches, switch reads, drop later. One statement that does all three is a demo of courage, not a migration. The rest of the list is quieter and just as expensive. An N+1 query that was fine at ten rows and hostile at ten thousand; a loop with no bound because the fixture had three items; tests generated to assert that the function returns whatever the function returns. Dependency ranges with no pin and no review, which is how a transitive package becomes the incident. No request id in the logs, a deploy that is “merge to main,” and a license question nobody asked of a package that now sits on the payment path. None of this is exotic. It is what you get when the acceptance test is “it rendered.”

Hardening is an order, not a mood

A rewrite feels clean because it hides every unmade decision. It also hides the one path that already takes money. Keep the product. Change the contract it has with the world. The order matters because each step produces information the next one needs. Observability before a refactor, or you polish the wrong function. Tests after you know what an incident looks like, or you buy coverage and miss the bug. The security boundary before the visual cleanup, or you ship a prettier hole.

Seven numbered steps on a line; an arrow from each to the next.
The hardening order. Each step produces what the next one needs.
  1. Inventory what exists

    List the running surfaces: routes, jobs, queues, webhooks, admin pages, scripts that still have production credentials, the one Cloud Function nobody remembers. Write down where state lives and who can reach it. A repo search for process.env, sk_live, BEGIN PRIVATE KEY, and TODO is a start, not a complete map. You cannot harden a system you cannot name.

  2. Find the three things that can hurt you

    Not thirty findings. Three. What loses data, money, or trust if it is wrong this week: the charge path, the export, the endpoint that takes an id from the client. Everything else waits. A long list is how hardening turns into a rewrite by another name.

  3. Fix the security boundary first

    Put authorization next to the data, not in the component that happens to render it. Derive the tenant from the session, not from the query string. Move secrets out of the bundle and out of git. Rotate anything that has ever been committed. Close the admin route that the model left on the same origin with no extra check. Do this before you rename folders. A tidy codebase with an open invoice list is still an open invoice list.

  4. Add observability before improving anything

    You need a request id, structured logs, and an error that reaches a person. You need to know whether the charge ran, not whether the page loaded. Add the probe that would have told you about Thursday’s tenant leak: who asked for which orgId, and whether it matched the session. If you cannot see a failure, you will spend the next week “improving” a function that was not the problem.

  5. Write the tests that would have caught the incident

    Coverage is a vanity metric when the suite asserts the code does what the code does. Write the case that would have failed on Thursday: user A requests user B’s invoices and is refused. Write the case where chargeCustomer throws and the response is not { ok: true }. One test that names a real failure is worth a generated file that names none.

  6. Make deploys reversible

    A forward-only migrate-on-boot is not a release process. You need a deploy you can undo, a migration you can reverse or expand, and a flag that turns a new path off without a rebuild. If the only rollback is “git revert and hope the schema agrees,” you do not have a rollback. You have a wish.

  7. Then, and only then, refactor

    Now you may rename the module, split the file the model left at nine hundred lines, and delete the unused client the scaffold created. Do it behind the tests and the logs you just added. Refactoring first is how teams spend a month making the leak more elegant.

Treat every model patch like a change from a contractor you have not met: read the diff, run the path that can lose money, and refuse anything whose authorization story is “the UI does not show that button.”

What to keep

The speed was the point. Do not punish the loop that got you a product. Change what “done” means. Done is not “the page rendered for me.” Done is: the route refuses the wrong caller, the failure is visible, the deploy comes back, and the test that would have caught Thursday exists before Friday’s feature. The model can still write the first draft of the route. A person still decides whether that route may exist. A team that does this well does not look slower in the afternoon. It looks boring. Someone pastes a prompt. Someone reads the diff. Secrets stay in a manager, not in the client bundle. Authorization is a gate in the handler, not a comment in the ticket. Logs carry a request id. The deploy has a button that undoes it. When the model invents a dependency, a person opens the registry page before the lockfile changes. They still ship on a Tuesday. They do not find out on Thursday that orgId was a suggestion. The invoice endpoint can stay. It just no longer believes the query string.

Related practice

AI Engineering

Was this useful?

Request a briefingGet an estimate