Independent research keeps landing on the same picture: 98% of 1,072 scanned Supabase-backed vibe-coded apps had at least one security issue, 16% of them critical (Symbiotic Security, 2026), and separately, 62% of AI-built applications ship with critical security vulnerabilities (OX Security, 2026). This isn't a fringe problem. With roughly 90% of US developers now using AI coding tools (Stack Overflow 2025 Developer Survey) and a quarter of one YC batch shipping codebases that were ~95% AI-generated (YC/SaaStr), the ten gaps below aren't edge cases, they're closer to the median AI-built app.
01Lock down row-level security, or your database is effectively public
The problem: the database will happily serve any row to anyone who can form a valid request, unless a policy explicitly says otherwise. In a vibe-coded app, row-level security is frequently left disabled, or enabled with a policy so broad ( using (true) ) that it changes nothing.
Why AI tools produce this: the fastest way to make a demo "just work" is to remove anything that returns permission errors during testing. An AI assistant optimizing for "the feature works" will happily loosen or disable a policy that's blocking its own test request, and rarely re-tightens it afterward unless told to.
The fix: enable RLS on every table containing user data, write a policy scoped to the owning user, and verify it by attempting to read another user's row as a different logged-in user.
alter table public.orders enable row level security;
create policy "users read their own orders"
on public.orders for select
using (auth.uid() = user_id);
create policy "users insert only their own orders"
on public.orders for insert
with check (auth.uid() = user_id);
Review every table in this schema and tell me, for each one: is
row-level security enabled, and if so, does the policy actually
restrict rows to the owning user or role? List every table where
RLS is off, or where the policy would allow a user to read or write
a row they don't own. Don't fix anything yet, just the audit list.
02Get secrets out of the client, and rotate every key that was ever exposed
The problem: a Stripe secret key, an OpenAI key, or a service-role database key ends up bundled into the JavaScript the browser downloads, visible to anyone who opens dev tools.
Why AI tools produce this: the client-side call is usually the fastest path to a working feature in a demo, and any environment variable prefixed a certain way (like NEXT_PUBLIC_ in Next.js, or VITE_ in Vite) gets bundled into the client automatically. An assistant chasing "make the API call work" will reach for whichever key is already available in scope, client or server, without distinguishing between them.
The fix: move any call using a secret key to a server-side route, keep only publishable/anon keys in the client, and rotate anything that has ever been exposed, treating it as compromised regardless of whether you've seen misuse yet.
// never do this in client-side code
const stripe = new Stripe("sk_live_51N...", { apiVersion: "2024-06-20" });
await stripe.paymentIntents.create({ amount, currency: "usd" });
// server route only, the browser never sees this file's contents
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const { amount } = await req.json();
const intent = await stripe.paymentIntents.create({ amount, currency: "usd" });
return Response.json({ clientSecret: intent.client_secret });
}
Search my codebase for any API key, secret, or credential used
directly in client-side or browser-rendered code, including
anything in an environment variable that gets exposed to the
client by this framework's naming convention. List every instance
with the file and line, and tell me which of these need rotating
with the provider, not just moving in the code.
03Put real auth and session handling in, not just a login screen that works
The problem: login succeeds, but authorization, whether the logged-in user is allowed to do this specific thing, is checked inconsistently or only in the UI, and session tokens are stored somewhere a script on the page can read them.
Why AI tools produce this: "add login" is usually interpreted narrowly as authentication, proving who someone is, and the harder question of authorization, what they're allowed to touch once logged in, gets implemented ad hoc per feature, or skipped when the UI already hides the button.
The fix: store sessions in cookies with httpOnly, secure, and SameSite set, and check authorization server-side on every request that touches data, never relying on the UI hiding an action as the only protection.
res.setHeader("Set-Cookie", [
`session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`
]);
// and on every protected route, server-side:
const session = await getSessionFromCookie(req);
if (!session || session.userId !== resource.ownerId) {
return new Response("Forbidden", { status: 403 });
}
04Add input validation at the boundary, not just in the form
The problem: the front-end form checks that an email looks like an email and a price is a positive number, but the API route behind it accepts whatever arrives, including a request built by hand that skips the form entirely.
Why AI tools produce this: client-side validation is what makes the demo feel polished, so it gets built first and often exclusively. Server-side validation produces no visible UI feedback, so it's easy to treat as optional.
The fix: validate every request server-side against an explicit schema, reject anything that doesn't match, and never assume a value is safe because the UI would have caught it.
import { z } from "zod";
const CreateOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(50),
couponCode: z.string().max(20).optional(),
});
export async function POST(req) {
const parsed = CreateOrderSchema.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
}
// parsed.data is now safe to use
}
05Fix the data model and add indexes, kill the N+1
The problem: a page that loads a list of items, then loops through them and fires a separate database query for each one's related data. It works fine with ten rows in development and buckles at a few hundred in production. Compounding it, common filter and sort columns have no index, so every query does a full table scan as the table grows.
Why AI tools produce this: the nested-loop version is usually the most obvious way to write the code, and it returns correct results in a small local dataset, so nothing about testing it locally reveals the problem. Indexes are invisible until the table has real volume.
The fix: replace per-row queries with a single query that joins or batches the related data, and add indexes on every column used in a WHERE, JOIN, or ORDER BY.
const orders = await db.order.findMany({ where: { userId } });
for (const order of orders) {
order.items = await db.orderItem.findMany({ where: { orderId: order.id } });
// 1 query for orders, then N more, one per order
}
const orders = await db.order.findMany({
where: { userId },
include: { items: true }, // single joined query
});
-- add this once you know the real query patterns
create index idx_orders_user_id on public.orders (user_id);
create index idx_order_items_order_id on public.order_items (order_id);
06Add tests on the money paths, at minimum
The problem: zero automated tests, or tests that only cover trivial functions, while the paths that actually matter, signup, checkout, and anything that changes access or moves money, have never been exercised by anything but a human clicking through once.
Why AI tools produce this: generating a passing test for a pure function is easy and satisfying to demo. Generating a meaningful test for a checkout flow requires understanding the business logic and the failure modes worth checking, which requires exactly the judgment this whole list is about.
The fix: you don't need full coverage to start. Write tests for the handful of paths where a bug costs you money or trust: a successful checkout, a declined payment, and a webhook replay that must not double-grant access.
test("a declined card does not grant access", async () => {
const res = await checkout({ card: testCards.declined, plan: "pro" });
expect(res.status).toBe("declined");
const user = await db.user.findUnique({ where: { id: testUser.id } });
expect(user.plan).not.toBe("pro");
});
test("a replayed webhook event does not double-grant access", async () => {
await handleStripeWebhook(sampleCheckoutCompletedEvent);
await handleStripeWebhook(sampleCheckoutCompletedEvent); // same event, again
const grants = await db.accessGrant.findMany({ where: { userId: testUser.id } });
expect(grants.length).toBe(1);
});
07Add error handling and idempotency, especially on webhooks and payments
The problem: a webhook handler trusts the request body without verifying it came from the real provider, and re-processes the same event every time it's redelivered, which payment providers do automatically on any timeout or non-2xx response.
Why AI tools produce this: the happy-path version, "when this event arrives, do the thing," is what a straightforward prompt produces. Idempotency and signature verification are the parts of the spec that only show up if you specifically ask for the retry and abuse scenarios.
The fix: verify the signature against the raw request body, and record every processed event ID so a retry becomes a safe no-op instead of a duplicate charge or duplicate grant.
export async function POST(req) {
const sig = req.headers.get("stripe-signature");
const rawBody = await req.text();
let event;
try {
event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`signature check failed: ${err.message}`, { status: 400 });
}
const seen = await db.processedEvents.findUnique({ where: { id: event.id } });
if (seen) return new Response("ok");
if (event.type === "checkout.session.completed") {
await grantAccess(event.data.object.customer_email);
}
await db.processedEvents.create({ data: { id: event.id } });
return new Response("ok");
}
08Add logging, monitoring, and alerting
The problem: when something breaks, the first sign is a user's email or a support ticket, sometimes days after the failure started, because nothing in the system was watching for it.
Why AI tools produce this: logging and alerting add no visible feature and no demo value, so they're the first thing skipped under time pressure, and AI assistants don't add them unless the prompt specifically asks for observability, not just functionality.
The fix: log structured events for anything that changes state (signups, payments, permission denials), send errors to a tracking service, and set up at least one alert for "the app is down" and one for "the error rate just spiked."
try {
await grantAccess(event.data.object.customer_email);
logger.info("access_granted", { eventId: event.id, email: event.data.object.customer_email });
} catch (err) {
logger.error("access_grant_failed", { eventId: event.id, error: err.message });
throw err; // let it surface to error tracking, don't swallow it
}
A production database was deleted by an AI agent against explicit freeze instructions in one widely-reported incident (Replit / SaaStr, 2025). Monitoring wouldn't have prevented that action, but it would have surfaced it in minutes instead of whenever someone next opened the app.
09Set up CI, a real deploy pipeline, and backups you've actually tested restoring
The problem: deploys happen by pushing straight to production with no automated checks in between, and backups either don't exist or have never been restored, so nobody actually knows if they work.
Why AI tools produce this: CI/CD and backup verification are infrastructure concerns that sit outside "write me this feature," and they're exactly the kind of task that's easy to defer indefinitely because nothing visibly breaks until the day you need them.
The fix: add a CI pipeline that runs your tests and linter before anything reaches production, and schedule a recurring reminder to actually restore your latest backup into a scratch environment and confirm the data is correct, not just that a backup file exists.
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint
- run: npm test -- --run
# deploy step only runs if the above all pass
10Write the docs and runbook, so it isn't all in your head
The problem: the only complete understanding of how the app works, what each service does, and what to do when something breaks lives in one person's head. If that person is unavailable during an incident, or leaves, the app becomes unmaintainable overnight.
Why AI tools produce this: documentation isn't a feature the AI was asked to build, and vibe-coded apps are often iterated on so quickly, across so many small prompted changes, that no single conversation ever produced a full picture worth writing down.
The fix: write a short architecture doc (what services exist, what talks to what, where the secrets live) and a runbook for your two or three most likely incidents (payment webhook failing, database connection exhausted, deploy broke production), with the exact steps to check and fix each.
Based on this codebase, write a short architecture doc: list every
service and external API this app talks to, what each one is for,
and where its credentials are stored. Then write a runbook entry
for "the Stripe webhook is failing": what to check first, what logs
to look at, and how to safely replay a missed event without
double-processing it.
Where to start if you can only do one thing this week
Items one and two, row-level security and exposed secrets. Between the two research citations above, these are the findings that show up most often and cause the most damage per incident, and both are typically fixable in a single focused day once you know exactly where to look. Everything else on this list matters, but these two are the ones most likely to already be actively exploitable.
Frequently asked questions
Which of these ten should I fix first?
Row-level security and exposed secrets, items one and two. Those two account for the majority of real, exploitable findings in AI-built apps, and both are typically fixable in a day once identified.
Do I need to do all ten before I can have real users?
Items one through seven are the minimum bar for handling real user data and real payments safely. Items eight through ten (monitoring, deploy pipeline, docs) matter enormously but won't cause an active breach on their own, so they can follow shortly after launch if you're genuinely resource constrained.
Can my AI coding tool do all of this for me if I ask correctly?
It can implement most of it well once you specify exactly what to build, which is the point of the prompts in this article. What it won't do unprompted is decide on its own that these ten things are missing, so the job is knowing to ask, then verifying the result.