How to Secure AI-Generated Code: A Practical Checklist
The code works, the demo runs, the deploy succeeds — and none of that means it's safe. The same seven bugs come back over and over.
Here is the thing nobody tells you when you ship your first app with Cursor or Bolt: the code works, the demo runs, the deploy succeeds — and none of that means it's safe. I scan AI-generated SaaS apps for a living, and the same seven bugs come back over and over. Not exotic ones. The boring, well-documented kind that have been in the OWASP Top 10 for a decade.
The reason isn't that the models are dumb. It's that an AI assistant optimizes the path you asked for — “make the webhook work,” “let the frontend call my API” — and the security check was never on that path. You didn't ask for signature verification. So it didn't write any. The happy path runs; the security property is missing; nothing in your local dev experience tells you.
This is a checklist, not a lecture. Seven vulnerability classes, why the AI ships each one, and a concrete way to catch it — a grep, a prompt, a habit, or a tool. Run through it before your next deploy.
Why AI assistants ship these bugs
One idea explains all seven, so it's worth thirty seconds up front.
When you prompt an assistant, you describe an outcome: “add a Stripe webhook,” “build a contact form,” “let users upload an avatar.” The model writes the shortest correct-looking code that produces that outcome. Verification, authorization, input sanitization, origin checks — these don't change whether the demo works, so they get skipped. The model has also seen thousands of tutorial snippets in its training data that skip them too, because tutorials optimize for “look how few lines this takes,” not “ship this to production.”
So the failure mode is consistent: the AI made the happy path work, and the security property was never part of the happy path. Every item below is a specific instance of that one sentence. (I wrote about why traditional scanners miss these in Why Traditional SAST Tools Fail on AI-Generated Code — short version: they're tuned for a different kind of codebase.)
1. Hardcoded secrets
What it looks like. The assistant skips environment variables entirely and writes the key straight into the source:
const stripe = new Stripe("sk_live_51HxxxxxxxxxxREDACTED");
const supabase = createClient(url, "eyJhbGci...service_role...");Why it ships. Env vars add a step — create a .env, wire up loading, document it. A literal string just works. The model picks the version with fewer moving parts because that's the one that runs on the first try.
How to catch it. Grep before every commit:
grep -rEn 'sk_live_|sk-proj-|AKIA[0-9A-Z]{16}|service_role|ghp_[a-zA-Z0-9]{36}' src/The durable habit: a secret literal in source is a bug even if it's “just the test key.” Once it's in git history, deleting the line doesn't remove it — you have to rotate. Put a pre-commit secret scan in front of yourself so the question never reaches code review.
2. NEXT_PUBLIC_ secret leaks
What it looks like. The secret is in an env var — but with a prefix that ships it to the browser:
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51Hxxxxxx # now in the JS bundle NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-xxxxxxxx # anyone can drain your quota
Why it ships. A component reads process.env.OPENAI_API_KEY, the value comes back undefined in the browser, and the fix the model has seen a thousand times is “add NEXT_PUBLIC_.” The error disappears. The feature works. The key is now inlined into every chunk under /_next/static/, readable by anyone with DevTools.
How to catch it. Two checks, sixty seconds:
# 1. Any prefixed secret-shaped name in env files grep -rEi 'NEXT_PUBLIC_[A-Z_]*(SECRET|PRIVATE|TOKEN|PASSWORD|API_KEY|DATABASE)' .env* # 2. The check that doesn't lie — search the built output npm run build && grep -rEi 'sk_live_|sk-proj-|postgres://|service_role' .next/static
If a value shows up in .next/static, it's public, full stop. The rule: a secret never gets the prefix, and the code that uses it runs on the server. Full breakdown in Your NEXT_PUBLIC_ Env Var Is Shipping Your Secret Key to the Browser.
3. Missing webhook signature verification
What it looks like. A payment or event webhook that trusts whatever hits the endpoint:
app.post("/webhook", async (req, res) => {
const event = req.body; // no signature check
if (event.type === "checkout.session.completed") {
await grantProAccess(event.data.object.customer);
}
res.json({ received: true });
});Why it ships. You asked for “handle the Stripe webhook.” Parsing req.body and branching on event.type is handling it, as far as the demo is concerned. Verifying that Stripe actually sent the request is a separate concern you didn't mention, so the stripe.webhooks.constructEvent() call never appears.
The attack. Anyone who knows your endpoint URL can POST a fake checkout.session.completed and get free Pro access. No credit card involved. I wrote up the full version in The $10,000 Stripe Webhook Bug Hiding in AI-Generated Code.
How to catch it. The fix is four lines — verify the signature against the raw body before you trust anything:
const sig = req.headers["stripe-signature"]; const event = stripe.webhooks.constructEvent(req.rawBody, sig, endpointSecret); // only now is `event` trustworthy
The prompt habit: whenever you ask for a webhook handler, add “verify the webhook signature and reject unsigned requests.” Same rule for GitHub, Slack, Clerk, and any other provider that signs payloads.
4. SQL injection via template literals
What it looks like. User input dropped straight into a query string:
const user = await db.query(
`SELECT * FROM users WHERE email = '${req.body.email}'`
);Why it ships. Template literals are the natural way to build a string in modern JavaScript, and “build a query with the user's email” reads as a string-building task. The model reaches for the syntax that's most idiomatic for strings, not the one that's safe for SQL. Parameterized queries take an extra argument and look less like the tutorial.
The attack. An email of ' OR '1'='1 returns every row; '; DROP TABLE users; -- is the classic. Anything the user controls and you interpolate is an injection point.
How to catch it. Grep for query calls built with a template literal:
# flags query calls built with a backtick template string — # eyeball each one for interpolated user input grep -rEn 'db\.(query|execute|raw)\(`' src/
The fix is parameterized queries — pass values as arguments, never concatenate:
const user = await db.query("SELECT * FROM users WHERE email = $1", [req.body.email]);Prompt habit: “use parameterized queries, never string interpolation, for anything touching user input.” This also covers the cousins — NoSQL injection via unsanitized $where, and command injection via exec() with interpolated input.
5. Missing auth on routes
What it looks like. An API route that does the thing without checking who's asking:
// app/api/admin/users/route.ts
export async function GET() {
return Response.json(await db.user.findMany()); // who's calling? no idea
}
export async function DELETE(req) {
await db.user.delete({ where: { id: req.query.id } }); // anyone can delete anyone
}Why it ships. You asked for “an endpoint to list users” or “let admins delete users.” The route returns users. The demo, run while you're logged in as yourself, looks correct. “Only admins” was an assumption in your head, not a line in the prompt — so the authorization check, and often the ownership check (is this my record?), never gets written.
How to catch it. Inventory every route handler and ask two questions of each: who is allowed to call this, and does the code actually check that? For Next.js:
grep -rLE 'auth\(\)|getServerSession|currentUser|requireUser|clerk' app/**/route.ts
That lists route files with no visible auth call — your shortlist to review by hand. The two checks that have to be there:
- Authentication — is there a logged-in user at all?
- Authorization — is this user allowed to touch this resource? (The classic miss:
/api/orders/:idthat returns any order by ID, not just the caller's. That's IDOR / broken object-level authorization, OWASP's #1.)
Prompt habit: “add authentication and an ownership check to this route; return 403 if the caller doesn't own the resource.”
6. CORS wildcard with credentials
What it looks like. A CORS config that reflects the origin and allows credentials:
app.use(cors({ origin: true, credentials: true }));
// or the hand-rolled version:
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");Why it ships. You hit a CORS error in the console, asked the assistant to fix it, and the most reliable way to make a CORS error go away is to allow everything. origin: true reflects whatever origin asks. The error clears, the frontend talks to the API, and the demo works — from your own domain, where nothing looks wrong.
The attack. With credentials allowed and the origin reflected, any website your logged-in user visits can make authenticated requests to your API and read the responses — their account data, returned to an attacker's page. Full walkthrough in The CORS Misconfiguration Cursor Generates That Exposes Your API.
How to catch it. Grep for the combination:
grep -rEn "origin:\s*(true|['\"]\*['\"])" src/ && echo "check whether credentials are also on"
The fix is an explicit allowlist — never reflect, never wildcard, when credentials are involved:
const allowed = new Set(["https://app.example.com"]);
app.use(cors({
origin: (origin, cb) => cb(null, allowed.has(origin)),
credentials: true,
}));7. dangerouslySetInnerHTML (XSS)
What it looks like. User-controlled content rendered as raw HTML:
<div dangerouslySetInnerHTML={{ __html: comment.body }} />
<article dangerouslySetInnerHTML={{ __html: post.markdownAsHtml }} />Why it ships. You asked it to “render the user's bio” or “show the markdown as formatted HTML.” React escapes strings by default, so the rendered text shows literal <b> tags instead of bold — which reads as a bug. The assistant's fix is dangerouslySetInnerHTML, which makes the formatting render. It also makes a <script> in the user's bio render.
The attack. Stored XSS: an attacker puts <img src=x onerror=...> in a profile field, and every viewer's session runs it.
How to catch it. Grep for it, then sanitize what survives:
grep -rn "dangerouslySetInnerHTML" src/
Every hit needs a reason. If the HTML genuinely must render (a rich-text editor, rendered markdown), run it through a sanitizer like DOMPurify first:
import DOMPurify from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.html) }} />If it doesn't need to be HTML, just render the string and let React escape it.
The pre-deploy checklist
Print this. Run it before you ship anything an AI assistant wrote:
- ☐ Secrets — no key literals in source; nothing secret-shaped under
.next/staticor any client bundle. - ☐ Webhooks — every webhook verifies its signature against the raw body before trusting the payload.
- ☐ SQL / queries — no user input interpolated into query strings; parameterized everywhere.
- ☐ Routes — every handler checks authentication and ownership; no endpoint returns or mutates a record by ID without confirming the caller owns it.
- ☐ CORS — explicit origin allowlist, never
origin: trueor*when credentials are on. - ☐ HTML rendering — every
dangerouslySetInnerHTMLis sanitized or removed. - ☐ Headers & errors — no stack traces or secrets leaked in error responses; security headers set.
Build the habits into how you prompt
Catching bugs after the fact is slower than not generating them. Three habits move the work upstream:
- Name the security property in the prompt. “Add a webhook handler” gets you the vulnerable version. “Add a webhook handler that verifies the signature and rejects unsigned requests” gets you the safe one. The model will write the check if you ask for it — you just have to ask.
- Make the assistant review its own output. After it generates a route, ask: “What are the authentication and authorization requirements here, and does this code enforce them?” It's often capable of spotting the gap it just created, once the gap is the thing you're pointing at.
- Scan on a schedule, not on a vibe. Add one automated security scan to your loop so “is this safe” stops being a thing you remember to wonder about. The greps above are a fine start. When you want broader coverage, run a scanner built for this exact problem.
Where a scanner fits
I build XploitScan, a SAST-style scanner tuned specifically for the code AI assistants generate — Cursor, Lovable, Bolt, Replit, Claude Code. It has 210 rules covering every class above and a lot more, and findings come back in plain English with copy-paste fixes, not security-engineer jargon you have to decode.
Run it locally with no signup:
npx xploitscan scan .
Your code never leaves the machine; web scans run in memory and are never stored. There's a GitHub Action with SARIF output and a GitHub App that posts Check Runs on your PRs if you want it in CI, plus an MCP server (npx xploitscan-mcp) so your assistant can scan its own work. The free tier covers 30 rules and 5 scans a day, which is enough to run this checklist on every project. Findings map to SOC 2, ISO 27001, OWASP Top 10, and CWE if you need that for a vendor questionnaire.
I'll be honest about the limits, because a security tool that oversells itself is one you shouldn't trust. A scanner finds patterns. It catches the seven classes here reliably because they have recognizable shapes. It will not understand your business logic — whether this user should see that invoice is a question only you can answer. It's a fast first pass that catches the boring 80%, not a replacement for thinking about what your app is actually allowed to do. On a held-out third-party benchmark (OWASP NodeGoat, Juice Shop, DVNA, lodash, with hint comments stripped) it caught 15 of 15 issues where Bearer caught 9 and Semgrep caught 8 — and I publish the methodology and regenerate the numbers on every commit precisely because you shouldn't take a vendor's benchmark on faith.
The more durable takeaway, though, is the mental model from the top: the AI optimizes the path you asked for, and the security check was never on that path. Every bug in this checklist is that sentence wearing a different costume. Once you see it that way, you stop being surprised by the next one — and you start naming the security property in the prompt before the AI has a chance to skip it.
Want to run the checklist now?
Paste a snippet or point the CLI at your repo. Free. No signup.
Scan Your Code — Free