All Field Notes

The Secrets Your AI Already Leaked: Security Footguns in Vibe-Coded Apps

Security holes survive AI-built apps because they don't throw errors or fail tests; they just sit there, working. Why agents leak secrets and skip authorization, and the cheap checks that catch both.

SecuritySecretsAuthorizationAI-assisted development

Most bugs have the decency to announce themselves. Something throws an exception, or a test goes red and blocks the merge. Security holes are quieter than that. Leave a live API key hardcoded in the source and the app runs perfectly; the key works, and that's the whole problem. Ship an endpoint that never checks who's asking, and it hands back exactly the data it was built to return, to anyone who calls it. Nothing looks broken, because as far as the code is concerned, nothing is.

That's why security problems ride along in AI-built apps so reliably. The agent's bar for "done" is "I ran it and it worked," and by that bar every one of those holes passes. The leaked key works. The query that forgot to filter by tenant works too; it just returns other customers' rows next to yours. Nothing ever goes red, so nothing gets caught, and the next endpoint gets built by copying the one before it.

Four footguns turn up again and again — each invisible for its own reason, each with a cheap check that finally makes it go red:

FootgunWhy nothing goes redThe check that makes it go red
Secrets committed to git historyThe key works; the app runs fine either wayA history-aware secret scanner (gitleaks) as a required CI step
Authorization missing at the data layerThe UI only shows what a user should see, so the product looks correctA cross-tenant test: user A requests user B's data and gets turned away
No rate limit on a public endpointNormal traffic never pushes hard enough to trip itA test that fires a burst of requests and confirms the limit holds
Missing security headers and cookie flagsThe page renders fine without themA CI or deploy-time check that asserts CSP, HSTS, and cookie flags are present

The Secret Is Already in the History

The single most common thing we find in an AI-built repository is a real credential sitting in source control. It ends up there because the fastest way to make an integration work is to paste the key where the code can read it, and an agent has no instinct that sk_live_... deserves any more care than the string next to it. It runs. The demo goes well. And now the key is part of your git history.

That last part is the one people underestimate. Deleting the line in a later commit doesn't help, because the value is still there in the history for anyone who clones the repo. If the project was ever public, treat the key as already burned. Rotating it is the only thing that actually fixes the problem; removing the line just hides the evidence.

Catching this is cheap enough to do on day one. A secret scanner belongs in two places: a pre-commit hook for a fast local heads-up, and a CI step that actually holds the line, since anyone in a hurry can skip a local hook with --no-verify but nobody skips the build. For the CI gate you want a history scanner like gitleaks; its open-source CLI is free for organizations, where the hosted Action isn't. A current-tree linter like secretlint is a good pre-commit companion, but it won't catch a secret that only survives in an old commit.

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # full history — a shallow clone can't see a secret a later commit deleted
      - name: Scan git history for secrets
        run: |
          curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
            | tar -xz gitleaks
          ./gitleaks git . --redact --exit-code 1

The secret you're chasing is usually one that was already "deleted," which is why the gate has to see the whole history, not just the latest commit. And pin the scanner to a release you have actually watched catch a planted test secret: a scanner that silently matches nothing is worse than not having one.

There's a sibling problem worth naming: drift. Even when your secrets live in a proper store, they have to stay in sync across local, preview, and production. When one environment is missing a value you get a failure that reads like an ordinary bug, and the quickest way to unblock yourself is to paste the secret somewhere it doesn't belong, which is how a key you just cleaned up finds its way back into the code. Keeping a plain list of every secret, where it lives, and who owns it is enough to turn that from something people have to remember into something you can check.

Authorization the Agent Can't See

The second footgun is more structural, and it hides better. Ask an agent to add an endpoint and it starts from the nearest one that already works. If that original filtered its records in the UI but not in the query behind it, your new endpoint inherits the gap, and so does the next one built the same way. On screen everything looks right, because the interface only ever shows people what they're allowed to see. A direct API call, or a nightly export job, has no interface to hide behind, and it returns the lot.

The way out is to put the boundary somewhere a new route can't skip. A per-query where clause is already better than filtering in application code, but it still leans on every author remembering to add it; forget it and the query happily returns everything. The version that actually holds routes each request through a single tenant-scoped client, or a row-level-security policy in the database, so the check lives in one place instead of being re-applied by hand at every call site.

// Fragile: each call re-applies the tenant filter by hand. A new endpoint that
// drops the `where` clause silently returns every org's invoices.
const invoices = await db.invoice.findMany({ where: { orgId: session.orgId } });
 
// Sturdier: inject orgId into invoice reads in one place instead of at every
// call site. Each read operation needs its own hook — a by-id route goes
// through findUnique, not findMany — so the list is only as complete as you
// keep it. The database policy below is the backstop that catches the rest.
function tenantClient(orgId: string) {
  return db.$extends({
    query: {
      invoice: {
        async findMany({ args, query }) {
          args.where = { ...args.where, orgId };
          return query(args);
        },
        async findFirst({ args, query }) {
          args.where = { ...args.where, orgId };
          return query(args);
        },
        // Requires Prisma's extended where-unique input (GA since Prisma 5)
        // to accept orgId alongside the id.
        async findUnique({ args, query }) {
          args.where = { ...args.where, orgId };
          return query(args);
        },
        async findUniqueOrThrow({ args, query }) {
          args.where = { ...args.where, orgId };
          return query(args);
        },
      },
    },
  });
}
 
const scoped = tenantClient(session.orgId);
const invoices = await scoped.invoice.findMany();

The database can enforce the same boundary directly, which is worth doing even behind a scoped client: a Postgres row-level-security policy rejects a cross-tenant row no matter which code path reaches the table.

alter table invoices enable row level security;
-- Postgres skips policies for a table's owner, and vibe-coded apps often connect
-- as the owner (one database URL for both migrations and runtime), so without
-- this line the policy silently does nothing for the app's own queries.
alter table invoices force row level security;
 
create policy tenant_isolation on invoices
  using (org_id = current_setting('app.org_id')::uuid);

The policy only works if app.org_id is actually set, and it has to be set per transaction — a session-level SET on a pooled connection leaks the previous request's tenant into the next one:

// set_config's third argument (true) scopes the setting to the current
// transaction, and passing orgId as a bind parameter avoids string-building
// SQL by hand.
const invoices = await db.$transaction(async (tx) => {
  await tx.$executeRaw`select set_config('app.org_id', ${session.orgId}, true)`;
  return tx.invoice.findMany();
});

A regression test is what actually proves the boundary holds. Sign in as one user, ask for another user's record by id, and assert the response is a rejection rather than a payload:

import { describe, expect, test } from 'vitest';
import request from 'supertest';
 
import { app } from '@/server/app';
import { createInvoiceFor, signIn } from './helpers';
 
describe('cross-tenant authorization', () => {
  test('user A cannot read user B’s invoice', async () => {
    const userA = await signIn(app, '[email protected]');
    const userBInvoiceId = await createInvoiceFor(app, '[email protected]');
 
    const response = await request(app)
      .get(`/api/invoices/${userBInvoiceId}`)
      .set('Cookie', userA.sessionCookie);
 
    expect(response.status).toBe(403);
  });
});

OWASP's authorization testing guidance arrives at the same conclusion from the testing side. The check that counts is the one where user A asks for user B's data and gets turned away. That's exactly the request a happy-path demo never makes, which is why it has to be a test you sit down and write. (This demo-versus-production gap runs wider than security — we map the rest of it in The Difference Between a Demo and a Durable Product.)

Open Doors: Rate Limits and Headers

Two smaller gaps round out the set, and like the others they stay invisible until someone goes looking.

The first is a public endpoint with no rate limit. It behaves impeccably under normal traffic and then buckles, or quietly runs up a cloud bill, the first time someone points a script at it. AI-built apps tend to leave every route wide open, mostly because nothing in development ever pushes on them. The defenses don't have to be sophisticated, but they do have to fit the risk, and the risk isn't the same everywhere. A rate limit on a login form is really about credential stuffing. A rate limit on an LLM-backed route is about stopping a stranger from running up your model bill for an afternoon. Same mechanism, different reason, and it helps to be clear about which one you're solving.

Adding one is a few lines, not a project. On a Next.js route handler, @upstash/ratelimit backed by Redis is enough to stop the obvious abuse:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '60 s'),
});
 
export async function POST(request: Request) {
  const { success } = await ratelimit.limit(getClientIp(request));
  if (!success) {
    return new Response('Too many requests', { status: 429 });
  }
  // ... handle the request
}

If you're already on Cloudflare, a rate limiting rule does the same job at the edge, before the request reaches your app at all.

The second is security headers and cookie flags, which stay off until someone deliberately turns them on. A Content-Security-Policy, HSTS, a sensible Referrer-Policy, and the Secure / HttpOnly / SameSite attributes on your cookies are baseline hygiene rather than a finishing touch. They're also the kind of setting a framework upgrade or a hosting move will drop without telling you. OWASP keeps a header cheat sheet that works as a starting checklist. The real value in writing your headers down is that afterward you know where they're configured and can confirm they survived the last deploy.

How the Diagnostic Looks at This

These checkpoints are part of the full Post Code Labs consultation, which scores security as one of its twelve categories. Instead of one hand-wavy "is it secure?" verdict, each footgun above becomes its own checkpoint:

Secrets kept out of history. The repository and its full git history are scanned, and a committed credential is a Critical finding, because a later commit can't take it back.

Environment parity. Secrets stay consistent across local, preview, and production, so nobody has to reach for a risky shortcut to paper over a missing value.

Authorization at the data layer. Tenant and ownership checks sit close to the data and get tested with cross-user requests, rather than being assumed from the interface.

Rate limiting and abuse controls. Public endpoints carry protection that fits the way they would actually be attacked.

Security headers and cookie flags. A verifiable baseline is in place and holds up across framework upgrades.

The result is rarely "rewrite the app." It's usually a short, concrete list: rotate the key already in your history, add a scanner to CI, move one permission check down to the data layer, put a limit on the endpoint you can't afford to lose. (And if you're wondering whether the rest of the app has quietly crossed the same threshold, the scorecard in How to Know When Your Vibe-Coded App Has Outgrown the Vibe is the place to start.)

First Actions

None of these needs a rewrite:

  1. Rotate, then scan. Assume any credential that has ever been committed is compromised, and cycle it. Then add a secret scanner to both the pre-commit hook and CI so the next one gets stopped at the door.
  2. Write one cross-tenant test. Take your most sensitive record and prove that user A can't load user B's copy of it. Fix whatever that turns up.
  3. Move one check down a layer. Pull a single permission rule out of the UI or the handler and put it behind a tenant-scoped client, or a row-level policy.
  4. Limit the endpoint you'd miss most. Login, signup, or anything sitting in front of an LLM is a sensible place to start. Match the limit to the abuse it invites.
  5. Turn on the basic headers. Set CSP, HSTS, and secure cookie flags once, and leave a note about where they live so a future deploy doesn't undo them without anyone noticing.

The thread running through all of this is that none of these problems will raise its hand on its own. So the work is to build something that raises it for you: a CI gate that goes red, a test that fails, a commit that gets bounced. Each of those is just a way of dragging a silent problem into the open while it's still cheap to deal with.

References