How to security-review AI-generated code (the 5 patterns to look for)
Cursor / Lovable / Bolt / Copilot wrote your code. It compiles, it works, you shipped it. Before you do the same thing tomorrow, here are the 5 security patterns AI-generated code gets wrong, with the visual signature for each so you can spot them in code review.
AI-generated code has predictable security failure modes. The same model trained on the same internet will produce the same canonical bugs across millions of users' projects. Here are the 5 patterns that account for most of the AI-generated security debt — visual signatures, why they happen, and how to fix.
What it is
Cursor, Copilot, Lovable, Bolt, v0, Replit, Claude Code, and similar AI coding assistants generate code from prompts + retrieved context. Their training data emphasizes happy-path correctness over security-path correctness; their defaults reflect the canonical examples in their training corpus. The result is code that runs and looks right but ships specific bug classes the human reviewer is meant to catch — and rarely does.
Vulnerable example
// app/api/users/[id]/route.ts — Cursor wrote this
// PATTERN 1: trusts params.id without ownership check
export async function GET(req: Request, { params }: { params: { id: string } }) {
const user = await db.from("users").select("*").eq("id", params.id).single();
return Response.json(user);
}
// app/auth/login/route.ts — Copilot wrote this
// PATTERN 2: leaks user existence via error message
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await db.from("users").select("*").eq("email", email).single();
if (!user) return new Response("user not found", { status: 404 });
if (user.password !== password) return new Response("wrong password", { status: 401 });
// ...
}
// app/api/upload/route.ts — Lovable wrote this
// PATTERN 3: trusts client-supplied content type
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
await fs.writeFile(`./uploads/${file.name}`, await file.arrayBuffer());
return Response.json({ ok: true });
}
// lib/openai.ts — Bolt wrote this
// PATTERN 4: API key inline literal (will be moved later, fingers crossed)
const client = new OpenAI({ apiKey: "sk-proj-Ab12...XyZ9" });
// middleware.ts — v0 wrote this
// PATTERN 5: matcher does not match the routes you think
export const config = { matcher: "/admin/:path*" };
// (you have admin routes at /api/admin/* — middleware never runs)Fixed example
// PATTERN 1 fix: ownership check
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await getAuthenticatedUser(req);
if (!session) return new Response(null, { status: 401 });
const user = await db.from("users").select("*").eq("id", params.id).single();
if (!user.data || user.data.id !== session.id) {
return new Response(null, { status: 404 }); // 404 not 403 — don't reveal existence
}
return Response.json(user.data);
}
// PATTERN 2 fix: generic error
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await db.from("users").select("*").eq("email", email).single();
if (!user.data || !await verifyPassword(password, user.data.passwordHash)) {
return new Response("invalid credentials", { status: 401 }); // same error for both cases
}
// ...
}
// PATTERN 3 fix: explicit content-type + filename validation
export async function POST(req: Request) {
const session = await getAuthenticatedUser(req);
if (!session) return new Response(null, { status: 401 });
const formData = await req.formData();
const file = formData.get("file") as File;
// Validate content type against allowlist
const ALLOWED = ["image/png", "image/jpeg", "image/webp"];
if (!ALLOWED.includes(file.type)) {
return new Response("unsupported file type", { status: 400 });
}
// Sanitize filename — never trust client-supplied path components
const safeName = `${session.id}-${crypto.randomUUID()}.${file.type.split("/")[1]}`;
await fs.writeFile(`./uploads/${safeName}`, await file.arrayBuffer());
return Response.json({ filename: safeName });
}
// PATTERN 4 fix: env var only, never inline
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
// PATTERN 5 fix: matcher includes /api
export const config = { matcher: ["/admin/:path*", "/api/admin/:path*"] };How Securie catches it
apps/web/app/api/route.ts:22How to security-review AI-generated code (the 5 patterns to look for)
Securie's specialist fleet maps directly onto these 5 patterns. The broken-auth specialist (Day-1) catches Pattern 1 by sandbox-replaying a cross-user request. The auth_authz specialist catches Pattern 2 by detecting differential responses to invalid-user vs invalid-password. The file-upload specialist catches Pattern 3 by detecting client-controlled filename + content-type combinations. The secret_scanner specialist (Day-1) catches Pattern 4 with live-key validation + auto-rotation. The path-traversal + middleware specialists catch Pattern 5 by sandbox-replaying admin-route access from an unauthenticated context.
// PATTERN 1 fix: ownership check
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await getAuthenticatedUser(req);
if (!session) return new Response(null, { status: 401 });
const user = await db.from("users").select("*").eq("id", params.id).single();
if (!user.data || user.data.id !== session.id) {
return new Response(null, { status: 404 }); // 404 not 403 — don't reveal existence
}
return Response.json(user.data);
}
// PATTERN 2 fix: generic error
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await db.from("users").select("*").eq("email", email).single();
if (!user.data || !await verifyPassword(password, user.data.passwordHash)) {
return new Response("invalid credentials", { status: 401 }); // same error for both cases
}
// ...
}
// PATTERN 3 fix: explicit content-type + filename validation
export async function POST(req: Request) {
const session = await getAuthenticatedUser(req);
if (!session) return new Response(null, { status: 401 });
const formData = await req.formData();
const file = formData.get("file") as File;
// Validate content type against allowlist
const ALLOWED = ["image/png", "image/jpeg", "image/webp"];
if (!ALLOWED.includes(file.type)) {
return new Response("unsupported file type", { status: 400 });
}
// Sanitize filename — never trust client-supplied path components
const safeName = `${session.id}-${crypto.randomUUID()}.${file.type.split("/")[1]}`;
await fs.writeFile(`./uploads/${safeName}`, await file.arrayBuffer());
return Response.json({ filename: safeName });
}
// PATTERN 4 fix: env var only, never inline
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
// PATTERN 5 fix: matcher includes /api
export const config = { matcher: ["/admin/:path*", "/api/admin/:path*"] };Checklist
- Every API route with `params.id` checks ownership against the authenticated session.
- Every login / password-reset / signup route returns generic errors that do not reveal user existence.
- Every file upload validates content type from server-side inspection (not client-supplied), sanitizes the filename, and bounds the storage path.
- Every secret reads from `process.env.NAME!` — never an inline literal, never a `NEXT_PUBLIC_` variable.
- Every middleware matcher is tested with `curl` against the routes it's supposed to protect — including `/api/...` routes the AI may have missed.
- Every form-submitting route validates the input shape with Zod / Valibot / similar BEFORE any database operation.
- Every email-or-token-based flow includes expiry + single-use + constant-time comparison.
FAQ
Are these 5 patterns specific to one AI tool, or universal?
Universal. Cursor, Copilot, Lovable, Bolt, v0, Replit, Claude Code, and similar tools all train on largely overlapping internet code. The same canonical bugs appear in their outputs. The pattern matching is what differs (each tool has a different generation style); the bugs are the same.
Why don't AI tools just learn to generate secure code?
They are slowly. The bottleneck is training data — most public code in the world is the canonical-but-buggy pattern, so the models reproduce it. Tools trained on security-curated corpora (or tools that integrate runtime security feedback into training) will be safer; until then, the human review or automated review (Securie) is required.
Should I just stop using AI coding assistants?
No — productivity gains are real. The right model is: AI assistants for the bulk of the work, automated security review on every PR for the bug-class catch. Securie's Day-1 specialists run on every PR in 30-90 seconds; they catch the 5 patterns above structurally, no human review required for the canonical cases.
What about new bug patterns AI will invent?
The bug shape evolves with the framework, not just with the AI. Server Actions are a new framework feature with new bug classes; AI tools learn the new classes from the canonical-but-buggy examples in early adopters' code. Securie's specialist catalogue extends to cover new framework patterns as they emerge — see CLAUDE.md "Ships alongside the MVP" for the planned specialist surface.
Related guides
The service-role key bypasses every RLS policy you wrote. It exists for a reason; it leaks for many reasons. Here is the rule for when to use it, the patterns that leak it, and the recovery playbook when it does.
Vercel environment variables have three flavors (development, preview, production) and two scopes (server-only and NEXT_PUBLIC_). Mixing them up leaks production secrets. Here is the rule and the canonical bugs.
Row-Level-Security bypass is the most common data leak in vibe-coded apps. Here is exactly how it happens, how attackers find it, and how to fix it in Next.js + Supabase with one policy update.
BOLA is the top item on the OWASP API Security Top 10 for a reason — every AI coding assistant introduces it by default. Learn what it looks like in Next.js, how to exploit it, and how to fix it.