Tools like Cursor, Lovable, Bolt, v0, and Replit have changed who gets to ship software. A founder with a clear idea can have a working product in front of customers in days. That's a real achievement, and nothing in this post argues against it.
The catch is that these tools optimize for the thing you can see: the page renders, the button works, the data shows up. Security is mostly about what you can't see. It's about what happens when someone uses your app in a way you didn't intend, such as changing an ID in a URL, calling your API directly, or sending a thousand requests a minute. An AI assistant won't test for that unless you ask, and most people don't know to ask.
The five problems below are the ones that come up most often in apps built this way. None of them need a security background to check. Each section explains the problem, how to find out whether you have it, and what the fix looks like.
1. Your database is open to anyone with the public key
Most vibe-coded apps use Supabase or Firebase, and both let the browser talk to the database directly. That's convenient, but it means the key in your frontend code is public by design. Anyone can open developer tools and copy it. The only thing between that key and your entire database is the access rules you've written: row-level security (RLS) policies in Supabase, or security rules in Firebase.
If those rules are missing, or set to allow everything, any visitor can read every row in every table. This isn't hypothetical. In 2025 a researcher scanned 1,645 apps built with Lovable and found about 170 whose Supabase tables could be read or written using only the public key. The issue was assigned CVE-2025-48757. Lovable disputes it, arguing that each customer is responsible for their own data, and in a sense that's the point: the platform won't necessarily protect you.
How to check: in Supabase, open the Security Advisor in your project dashboard, which flags tables in the public schema with RLS disabled. In Firebase, open your Firestore and Storage rules and look for anything like allow read, write: if true, or test-mode rules with an expiry date.
2. Secret keys are shipped to the browser
Frameworks decide which environment variables reach the browser by their name. In Next.js, anything starting with NEXT_PUBLIC_ is bundled into client code. In Vite, it's VITE_. When an AI assistant hits an error like "key is undefined" in a component, the quickest fix is to add that prefix. The error goes away, and your Stripe secret key, OpenAI key, or Supabase service role key is now readable by anyone who visits the site.
How to check: look for secret-sounding variables with a public prefix, then search the production build for known key formats.
# 1. Public-prefixed variables that look like secrets
grep -rEn "^(NEXT_PUBLIC|VITE)_[A-Z_]*(SECRET|SERVICE_ROLE|PRIVATE|STRIPE_SK)" .env*
# 2. Known secret key formats in the built client bundle
npm run build
grep -rEo "sk_live_[A-Za-z0-9]{10,}|sk-proj-[A-Za-z0-9_-]{10,}|sb_secret_[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}" \
.next/static dist 2>/dev/null | sort -uThe fix: move the call that needs the secret into a server route or server action, and remove the prefix. Then rotate the key in the provider's dashboard. Rotating matters. If a key was ever in a public bundle or a public Git repository, assume someone has a copy.
3. Authorization only happens in the interface
This is the most common serious bug in web apps generally, not just AI-built ones, and the one automated scanners are worst at finding. The app hides the admin button from regular users, or only shows each user their own invoices, but the API behind it never checks. Anyone who calls the endpoint directly, or changes /api/invoices/1042 to /api/invoices/1043, gets data they shouldn't.
How to check: log in as one test user, copy a request from your browser's network tab, and replay it while logged in as a different test user, or with the ID changed. If you get data back, the check is missing.
// app/api/invoices/[id]/route.ts
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await getCurrentUser(req); // your auth helper
if (!user) return new Response("Unauthorized", { status: 401 });
const invoice = await db.invoice.findUnique({ where: { id } });
// Same response for "missing" and "not yours", so IDs can't be probed
if (!invoice || invoice.ownerId !== user.id) {
return new Response("Not found", { status: 404 });
}
return Response.json(invoice);
}The rule of thumb: the browser decides what to show, the server decides what's allowed. Every API route and server action should check who the user is and whether they're allowed to touch this specific record.
4. Nothing limits how often expensive endpoints can be called
Sign-up, password reset, one-time codes, and especially AI features all cost money each time they run. Without rate limits, a single script can send thousands of sign-up emails, run up your SMS bill, or burn through a month of model credits overnight. This is one of the most common ways a newly launched AI app gets hurt, and it often looks like a billing problem rather than a security one.
How to check: pick your most expensive endpoint and ask what would stop someone calling it 10,000 times in an hour. If the answer is nothing, add a limit.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute
});
export async function POST(req: Request) {
const user = await getCurrentUser(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const { success } = await ratelimit.limit(user.id);
if (!success) return new Response("Too many requests", { status: 429 });
// ...call the model
}Also set a hard monthly spending limit in your AI provider's dashboard. A rate limit controls how fast money goes out; a spending cap controls how much.
5. The app trusts the browser about money
Two patterns show up repeatedly. First, the checkout sends the price from the browser, so a user can edit the request and pay one cent. Second, the payment webhook doesn't verify that the request actually came from Stripe, so anyone who finds the URL can mark an order as paid.
How to check: search your checkout code for a price or amount that comes from the request body. Then open your webhook handler and look for a signature check.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Safe to act on event.type now
}For checkout, send only a product or price ID from the browser and look up the amount on the server.
Making the fixes stick
Fixing these once is the easy part. The harder part is that the next AI-generated change can quietly undo a fix, because the assistant doesn't remember why the code was written that way. A few habits help:
- Write a test for each fix, such as "user B gets a 404 for user A's invoice", so a regression fails the build.
- Run secret scanning and dependency checks in CI, not just on your laptop.
- Keep your database rules in migration files under version control, not only in the dashboard.
- Tell your AI assistant the rules explicitly, in a project instructions file that every session reads.
Where we stand: ShipLabs runs security assessments and production hardening for apps built with AI tools. Details of CVE-2025-48757 come from the public CVE record and published research. Only run these checks against applications you own or have written permission to test.
