How to secure a vibe-coded app: the 3 bugs a blank repo always ships

A coding agent starting from an empty folder reproduces the same three security bugs every time — un-revocable sessions, payment webhooks that charge twice, and queries that return another tenant's rows. Why it happens, and how a tested base closes all three.

MC
Martin Coll

The honest way to think about how to secure a vibe-coded app is to stop treating security as a pre-launch checklist you bolt on at the end, and start treating it as a property of the foundation you built on. When you point a coding agent at an empty folder and ask for auth, payments, and a dashboard, it doesn't invent a design — it averages years of public code: App Router mixed with Pages Router, five-year-old Stripe snippets, a login route copied from a tutorial. The average is fast, and it demos beautifully. But the average also includes the bugs.

Three of those bugs show up almost every single time. They're not exotic. They each pass code review, ship, and only surface when a real user (or a real attacker) does something the happy path never tested. The good news is that they're categories, not one-off mistakes — which means the right foundation closes all three before your agent writes its first line. Here's each one, why a blank repo reproduces it, and the exact mechanism in the useDeploy boilerplate that shuts it.

Bug 1: the session that outlives the password

The pattern, described almost verbatim across the boilerplate literature (as documented in buildthisnow's Claude Code write-up, citing Makerkit's own docs): "Sessions that do not log out on password change. You reset your password, but the old login token still works."

Why does a from-scratch build do this? Because the agent reaches for the simplest auth it knows: a self-signed, stateless JWT. The token carries the user id and an expiry, it's signed with a secret, and — critically — the server keeps no record of it. "Log out" just deletes the cookie in the browser. There is nothing on the server to revoke, so a token that leaked, or an old token a user thought they'd killed by resetting their password, stays valid until it expires. Revocation isn't broken; it's impossible by construction.

useDeploy doesn't hand-roll auth at all. It wires BetterAuth with the official Prisma adapter, which means every session is a row in Postgres, not a self-contained bearer token:

database: prismaAdapter(prisma, { provider: 'postgresql' }),
// ...
// Rolling session model — expiresIn caps the lifetime; updateAge
// controls how often an active session's expiry is refreshed.
session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24 },
advanced: {
  useSecureCookies: cfg.isProduction,
  defaultCookieAttributes: {
    httpOnly: true,
    sameSite: cfg.isProduction ? 'none' : 'lax',
    secure: cfg.isProduction,
    path: '/',
  },
},

Because auth is a server-side lookup on every request, a session is revocable: delete the row and the token is dead on its next use. signOut does exactly that. The cookie is httpOnly and Secure, so client-side JavaScript can't read it and it never travels over plain HTTP. Passwords are hashed with argon2id (the OWASP default), not a homegrown salt-and-SHA.

The "log out everywhere" behavior is then a policy you own, not a wall you can't build. The change-password flow ships conservative — rotating your password from settings doesn't nuke your other tabs — but the switch is one boolean:

await this.auth.api.changePassword({
  body: {
    currentPassword: input.oldPassword,
    newPassword: input.newPassword,
    revokeOtherSessions: false, // flip to true to end every other session
  },
});

That's the whole distinction. The blank-repo bug is that sessions can't be revoked. Here they always can — server-side, on demand — and whether a credential change also ends concurrent sessions is a flag, not a rewrite.

Bug 2: the webhook that charges twice

Second recurring bug, again near-verbatim: "Stripe webhook handlers that are not idempotent… if Stripe sends the same message twice (which it does), your app charges or credits the user twice." Payment providers guarantee at-least-once delivery — retries on timeouts, redeliveries after your 200 got lost — so a handler that does credits += 1 on every call is a double-credit waiting for a network blip.

And the more visceral cousin of the same bug is no verification at all. A widely-cited Hacker News thread tells it plainly: a vibe-coded SaaS shipped with the Stripe key exposed on the front end, "a hacker got a hold of his Stripe key and issued every customer a refund," and now the builder is "hiring a developer to shore it up." The webhook path is where money meets untrusted input, and a blank repo tends to trust it completely.

useDeploy treats the incoming webhook as two separate problems: is this real? and has this already happened? The first is a signature check that runs before anything is parsed. The MercadoPago adapter, for example, rebuilds the provider's signed manifest and compares it in constant time:

const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
const expected = crypto
  .createHmac('sha256', this.cfg.webhookSecret)
  .update(manifest)
  .digest('hex');
// timing-safe compare — a forged or replayed signature is rejected
return crypto.timingSafeEqual(hashBuf, expectedBuf);

Only a verified event gets normalized and published onto a durable transactional outbox, whose own docstring is blunt about the contract: "Delivery is at-least-once per event… Handlers must be idempotent or tolerate the rare duplicate." So the second problem — "has this already happened?" — is solved where the money actually lands: recording a paid order is an atomic upsert keyed on the provider's unique order id, with a deliberately empty update branch.

async createIfMissing(input: CreatePurchaseInput): Promise<Purchase> {
  // Atomic upsert keyed on the unique externalOrderId. The `update`
  // branch is intentionally a no-op so duplicate webhook deliveries
  // resolve to the original row without overwriting paidAt or metadata.
  const row = await this.prisma.purchase.upsert({
    where: { externalOrderId: input.externalOrderId },
    create: {
      /* customerInternalId, provider, priceId, amountCents, currency, paidAt… */
    },
    update: {},
  });
  return toDomain(row);
}

Send the same invoice.paid twice and the second delivery resolves to the original row. No second charge, no second credit, no second confirmation email. Authenticity and idempotency are kept as two distinct guarantees, because conflating them is how the double-charge slips back in.

Bug 3: the query that returns everyone's rows

The third bug is the one that makes the news. In a multi-user app, the danger isn't a create that forgets its owner — the schema forces you to supply one. It's a read: a findMany that forgot its where: { organizationId }. It typechecks. It passes review. It ships. Then one customer lists "the members" and gets every org's members, because at the ORM layer, "list the members" and "list this org's members" are the same call minus one clause.

This is not a hypothetical. Security scans of vibe-coded apps in early 2026 reported hundreds of Lovable projects that shipped with Postgres Row-Level Security disabled entirely — databases wide open to anyone with the public key (as reported by Autonoma's vibe-coding security review, as of 2026). The non-technical builder never knew RLS existed; the agent never thought to turn it on, because — as one developer put it in a much-quoted line — "AI does what you ask. It just never thinks about what you didn't ask."

useDeploy closes this category with a Prisma client extension that makes a forgotten tenant filter throw instead of return rows. For a small allowlist of org-owned models, any read or mutation of existing rows must prove its where constrains organizationId — or it fails loudly on first execution:

export const assertTenantScoped = (model, operation, args): void => {
  if (!TENANT_SCOPED_MODELS.has(model)) return;
  if (!GUARDED_OPERATIONS.has(operation)) return;
  const where = args?.where;
  if (!hasOrgConstraint(where)) {
    throw new TenantScopeViolationError(model, operation);
  }
};

A missing filter that returns rows is a data leak you find in a support ticket. A missing filter that throws is a stack trace in your test run. That flip — from silent leak to loud failure — is the whole point, and it's the kind of wall an agent can't accidentally step over because it lives in the query path, not in a policy the agent never saw. It has honest limits (raw $queryRaw isn't intercepted, so hand-written SQL is still your job), and the full design — the model allowlist, the where-shape predicate, the system-client escape hatch — is walked through in the deep-dive on the tenant-isolation guard. The org, membership, and role model it sits under lives on the multi-tenancy feature page.

The pattern underneath all three

These aren't three unrelated mistakes. They're three instances of the same one: the happy path is a prompt, and the safe path is architecture. Charging a card that works is a prompt. Charging it once when the provider retries is a design decision. Signing a user in is a prompt. Making that session revocable is a design decision. Listing rows is a prompt. Listing only this tenant's rows, provably, is a design decision. An agent optimizing for a green demo will take the prompt every time.

The emerging consensus in the space isn't "boilerplate or AI" — it's boilerplate as the foundation for AI: ship the dangerous 70% already built and guarded, then let the agent vibe-code the 30% that makes your product yours, inside walls it can't quietly knock down. Securing a vibe-coded app, in that frame, isn't a pre-launch audit sprint. It's picking a base where the un-revocable session, the double-charging webhook, and the cross-tenant leak were never on the table to begin with.

If you're at the point where the mockup works and real users are about to sign in and pay, that's the moment this matters. Start with the parts that gate access to other people's data and money: the auth and multi-tenancy surfaces are where these three categories are closed by default — so the only security work left is the kind that's actually specific to your product.