AI coding assistants ship code that compiles, runs, and looks correct in review. They also ship three specific classes of vulnerabilities that aren't in their training data — because the training data is GitHub, and GitHub doesn't store the bugs people fix in private branches before pushing. Cursor, v0, Lovable, and Bolt have all materially changed what the average MVP looks like under the hood. They haven't changed what an attacker tries first.
This post is for the founder who built their MVP with an AI assistant, never wrote the auth middleware themselves, and is about to put it in front of paying customers. You can do most of this audit in an afternoon. The parts you can't are the parts worth paying for.
If you ship a Cursor-generated MVP without doing the BOLA check below, you will be in the news. Maybe not today. Maybe not your launch week. But within 90 days of your first 100 users, someone bored will iterate your
/api/items/1endpoint to/api/items/2, and the only thing standing between you and a disclosure email is whether you wrote one WHERE clause correctly.
The AI-coded MVP attack surface
A human writing their first Express or Next.js API tends to forget one or two security checks. An LLM completing a route handler tends to forget the same one or two — every single time. The bias is consistent enough that you can predict the bug from the prompt.
Here are the three classes I see in 80% of AI-generated MVPs I review:
1. The missing tenant scope (BOLA / IDOR)
You prompt: "create an API route to fetch a project by ID." The LLM emits:
// app/api/projects/[id]/route.ts — generated, dangerous
export async function GET(req, { params }) {
const session = await getServerSession(authOptions)
if (!session) return Response.json({ error: 'unauthorized' }, { status: 401 })
const project = await db.project.findUnique({
where: { id: params.id }
})
return Response.json(project)
}
The auth check is there. What's missing is the ownership check. Any logged-in user can read any project by guessing or iterating the ID. This is the #1 finding in indie-dev pentests, and it's the bug class behind the Lovable disclosure (CVE-2025-48757) that exposed 170+ generated apps in 2025, including one with 13,000 user records.
The fix is a single clause:
const project = await db.project.findUnique({
where: { id: params.id, ownerId: session.user.id }
})
if (!project) return Response.json({ error: 'not found' }, { status: 404 })
Six characters of text. Every AI assistant I've tested will skip it unless you explicitly remind it in the system prompt.
2. Supabase tables without RLS
If your stack is Supabase + a generated frontend, here's the brutal default: Supabase ships new tables with Row-Level Security disabled. The anon key, which is in your client bundle and therefore public, can query any table that doesn't have an explicit RLS policy. 83% of exposed Supabase instances analyzed in 2025 had this exact misconfiguration.
You can verify in 30 seconds:
# Query your own database with the anon key
ANON_KEY="your-public-anon-key"
SUPA_URL="https://your-project.supabase.co"
curl -s "$SUPA_URL/rest/v1/users?select=*" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" | head -c 500
# If you see any user data without logging in, you have a missing RLS policy.
Walk every table in your schema. If RLS is off, either turn it on with a policy or move the table behind a server-only API.
3. Server secrets that leaked into the client bundle
Cursor will happily import a server-side helper into a Client Component if the file paths line up. "use client" on top of a file, plus an import that pulls in process.env.STRIPE_SECRET_KEY further down the dependency tree, equals your secret key shipped to every browser visiting your site.
# Build, then grep your output bundles for secret patterns
npm run build
grep -rE "sk_(test|live)_|service_role|PRIVATE_KEY|xoxp-|ghp_" .next/static/ \
&& echo "SECRET FOUND IN CLIENT BUNDLE" || echo "Clean"
The defensive move is to install server-only and add import 'server-only' at the top of any module that touches a secret. The build will fail loudly the moment you accidentally import it from a client component.
The 5-minute self-audit checklist
Open a terminal. Set APP=https://your-mvp.com. Run these.
Check 1: Are your API routes public?
for path in /api/me /api/users /api/projects /api/admin /api/settings; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$APP$path")
echo "$path -> $code"
done
# Anything that returns 200 without auth needs an explicit decision: should it be public?
Check 2: Does cross-user access return 200?
Sign up two accounts. From account B's session, request a resource that belongs to account A:
curl -s -H "Cookie: $B_SESSION" "$APP/api/projects/$A_PROJECT_ID" -w "\n%{http_code}\n"
# 403 or 404 = good. 200 with A's data = critical BOLA.
Check 3: Are security headers set?
curl -sI $APP | grep -iE "content-security-policy|strict-transport|x-frame-options|x-content-type-options"
# Missing CSP is the single most common omission in Cursor-generated Next.js configs.
Check 4: Is your Supabase anon key omnipotent?
The check above. If a curl with just the anon key returns table contents you wouldn't show a stranger, fix the RLS policy.
Check 5: Did secrets leak to the client?
npm run build && grep -rE "sk_|service_role|PRIVATE_KEY" .next/static/ public/
If you can run these five checks and every result is what you want, you've eliminated the bugs that would otherwise turn your launch announcement into a disclosure thread.
The bugs you can't catch from the outside
The five-minute checklist covers the bugs that are visible from outside the application. There's a second class that isn't: chained vulnerabilities where each individual finding is low-severity but combined they let an attacker escalate. Examples I've found in MVP audits:
- Webhook endpoint without signature verification + a route that trusts the webhook payload's
userIdfield. Result: anyone can call your webhook with a forged payload and impersonate any user. Neither half is exploitable alone. - An admin endpoint protected by role check, plus a profile-edit route that lets you set arbitrary fields including
role. Result: any user makes themselves admin in one PATCH request. - A file-upload route that accepts any extension, plus a static-serving config that executes
.htmlwith same-origin cookies. Result: stored XSS via uploaded HTML.
These aren't visible from curl. They show up when someone reads the actual routes and follows the data. That's the part of the audit you either learn to do, or you pay someone to do.
When to hire
Hire when one of these is true:
- You're storing real customer data (anything regulated, anything financial, anything that would be embarrassing in a breach disclosure).
- You're about to launch publicly and a botted weekend is realistic (Product Hunt, HN front page, a paid ad burst).
- You have B2B customers about to ask for a security questionnaire.
- Your AI assistant generated more than 50% of your code and you can't confidently explain what each route does line-by-line.
The pre-launch 8-item checklist is the manual version of what we automate. The free headers scan takes 60 seconds and surfaces the misconfigurations visible from the outside. The sample report shows what a full audit looks like — including the chained findings you can't surface with curl. If you want the AI-assistant-trained tooling, the agent we built sits in front of these and runs them in your terminal before you deploy.
You don't need to be a security engineer to ship a safer MVP. You need to know which checks an AI assistant won't generate on its own — and to run them before the first user logs in.
A real-world walkthrough: what 30 minutes on a Cursor MVP looks like
Here is a sanitized version of what the first half-hour of a real audit looked like on an MVP that was built almost entirely with Cursor and shipped to about 600 paying users. Names and routes are changed, the findings are not.
The founder said: "I think we're fine — Cursor wrote most of it, but I read every diff before merging."
Minute 1 to 5: I ran the route enumeration check above. Out of 38 API routes, two returned 200 without a session: /api/internal/debug (left over from local dev, returned environment hints) and /api/team/invite (a route that accepted a team ID and an email and dispatched an invitation, no auth required at all). The second one was reachable from the internet and would let anyone invite themselves into any team in the database.
Minute 6 to 12: I created two test accounts, captured the project ID from account A, and requested it from account B. /api/projects/[id] returned 200 with full project contents including the assigned API key. Classic BOLA. Three more routes had the same shape. The founder had reviewed each diff and missed the same omission four times.
Minute 13 to 18: I checked the Supabase anon key against three tables I could see referenced in the client bundle. Two had RLS enabled with sensible policies. One — the password_reset_tokens table — had RLS disabled entirely. The reset tokens were long enough to be unguessable, but the table was readable in bulk: an attacker with the anon key could list every active reset token and complete account takeover for any user with a pending reset.
Minute 19 to 25: I built the app and grepped the bundle. A single client component was importing a server-side analytics helper that read process.env.POSTHOG_PROJECT_KEY at module scope. The key was in the bundle. Not catastrophic on its own (PostHog project keys are designed to be public), but the same pattern in a different file would have shipped Stripe secrets the same way. The founder had no idea client and server module graphs could overlap.
Minute 26 to 30: Headers check. No CSP, no HSTS, default Next.js response headers only. Permissive CORS on every /api/* route because Cursor had generated a "fix the CORS error" middleware that returned Access-Control-Allow-Origin: * globally during local testing and was never tightened.
Total findings in 30 minutes: six issues, four of them genuinely critical, all of them caught by the same five-minute checklist this post outlines. None of the bugs were exotic. The founder fixed them in an afternoon. The reason they shipped in the first place was that Cursor never raised any of them.
FAQ
Is code generated by Cursor or v0 inherently insecure?
No. It's insecure the same way code generated by a junior dev is insecure: the boilerplate is correct, the auth check is present, and the tenant-scope clause is missing. The bias is consistent. The fix is consistent. The mistake is treating "it works" as "it's safe."
Will adding "make this secure" to my prompt fix it?
Partially. Telling the model to add ownership checks and RLS policies measurably improves output, but it doesn't catch the chained vulnerabilities and it doesn't enforce consistency across files. The model will add the ownership check to the file you're editing and skip it on the next route you generate three hours later.
Do I need a SOC 2 audit before my first customer?
No. SOC 2 is a compliance audit, not a security audit. They overlap, but a SOC 2 report doesn't catch BOLA or RLS misconfigurations. Run the technical audit first; pursue compliance when a customer asks for the report.
How often should I re-run this checklist?
Before every public launch or major release. The whole point is that AI assistants regenerate the same class of bugs every time you add a feature, so the audit isn't one-and-done.
What's the difference between this and a regular pentest?
A pentest looks at the running app. This checklist looks at the bug classes that AI assistants disproportionately introduce. Both are useful. If you're shipping a Cursor-built MVP and can only do one, do this first — most pentests will catch the same bugs but cost ten times as much because they're calibrated for enterprise codebases.
Ship safer. Run the five-minute checklist. If anything comes back red, fix it before launch — your future self will thank you.