Reviewing AI-Generated Code for Security: The Patterns That Actually Bite
The specific vulnerability classes that show up disproportionately in model output, and how to catch them in review.
After two years of reviewing pull requests that were mostly written by Copilot, Cursor, and Claude Code, I've stopped thinking about AI-generated vulnerabilities as random. They cluster. Models are trained on a corpus that overrepresents tutorial code, Stack Overflow answers from 2015, and repositories that were never security-reviewed. The output inherits those biases. If you know the clusters, review gets faster and your false-negative rate drops.
This is not a call to distrust the tools. I ship AI-assisted code every day. It's a checklist for the reviewer, human or automated, sitting downstream of the generation.
Why AI code fails differently
A model optimizes for plausible, runnable code that matches the prompt. Security is a non-functional property that rarely appears in the prompt and almost never breaks a test. So the failure mode is not "the code doesn't work" — it's "the code works perfectly and is exploitable." That's the worst kind for review, because your eye is drawn to the happy path that just passed CI.
The second structural issue: models produce locally correct code with no global context. It doesn't know that the userId flowing into this function came from a JWT that your gateway does not actually verify. It fills the gap with an assumption, and the assumption is usually the optimistic one.
Pattern 1: Injection through string-built queries and commands
This is still the most common one I see, because so much training data predates the parameterized-everything era. Ask for "a function to look up orders by customer name" and you'll frequently get concatenation.
// Generated, and it runs fine in the demo
const rows = await db.query(
`SELECT * FROM orders WHERE customer = '${name}'`
);The fix is boring and known, but the review discipline is the point: any time model output builds a query, shell command, file path, or template from a variable, treat it as guilty until proven parameterized.
const rows = await db.query(
'SELECT * FROM orders WHERE customer = $1',
[name]
);The same reflex applies to child_process.exec with interpolated arguments (should be execFile with an args array), to os.system in Python, and to any ORM "raw" escape hatch. A useful grep before merge:
rg -n "exec\(|execSync|os\.system|subprocess.*shell=True" --type-add 'src:*.{js,ts,py}' -tsrcPattern 2: Authentication that authenticates, authorization that doesn't
Models are good at "is this user logged in" and bad at "is this user allowed to touch this specific object." Broken object-level authorization (BOLA/IDOR) is the single most dangerous gap I find in AI-assisted endpoints, because it's invisible in a single-user test.
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: str, user=Depends(current_user)):
# Authenticated. Not authorized.
return db.invoices.find_one({"_id": invoice_id})Nothing checks that the invoice belongs to user. Change the ID in the URL and you read someone else's data. When you review any handler that takes an object identifier, ask one question: where is the ownership or tenancy check? If you can't point at the line, it isn't there.
doc = db.invoices.find_one({"_id": invoice_id, "org_id": user.org_id})
if not doc:
raise HTTPException(404)
return docPattern 3: Secrets, and the confident hardcoding of them
When a model needs a config value it doesn't have, it invents a placeholder that looks real enough to survive review: api_key = "sk-...", a default JWT secret of "your-secret-key", a fallback DB password. The dangerous version is the fallback, because it ships:
const secret = process.env.JWT_SECRET || 'dev-secret-change-me';That || means production runs on a public string if the env var is ever missing. Fail closed instead — throw at startup if the secret is absent. Run gitleaks or trufflehog in CI regardless, and keep it, because the pattern recurs constantly.
Pattern 4: Weak or misapplied crypto
The corpus is full of MD5 and SHA-1. Ask for "hash the password" and you have a real chance of getting a fast general-purpose hash instead of a password KDF. You'll also see ECB mode, static IVs, and Math.random() used for tokens.
# Wrong tool: fast hash for passwords
hashlib.sha256(password.encode()).hexdigest()Password storage wants argon2id or bcrypt. Tokens want secrets.token_urlsafe, not random. If you see Math.random() anywhere near a session ID, password reset, or nonce, flag it. The generated code will look tidy; tidiness is not entropy.
Pattern 5: Deserialization and overbroad parsing
Python pickle.loads on anything network-adjacent, yaml.load without SafeLoader, Node code that evals a config file. Models reach for these because they're the shortest path to "turn bytes into an object." Any deserialization of untrusted input is a review stop.
Pattern 6: SSRF in the age of agentic features
In 2026 half the features I review fetch a URL the user supplied — "summarize this link," webhook senders, image proxies. Generated fetch code almost never validates the destination, so it will happily hit http://169.254.169.254/ or your internal admin service.
// No host validation at all
const res = await fetch(userProvidedUrl);Require an allowlist of schemes and hosts, resolve the DNS name and reject private/link-local ranges, and disable redirects or re-validate on each hop. This is exactly the kind of context a model can't infer from the prompt, so it's on you.
Pattern 7: Missing output encoding and unsafe rendering
React lulls people into safety, then a model reaches for dangerouslySetInnerHTML to "render the description" and you have stored XSS. Same story with v-html, with manual DOM building, and with server templates that disable auto-escaping. Trace where the string came from; if a user ever influenced it and it lands in HTML unescaped, stop.
How to run the review without slowing to a crawl
You cannot manually audit every AI diff at this depth. Layer it.
- Automate the mechanical catches. Semgrep with the community rulesets plus a few house rules catches injection, weak crypto, and dangerous sinks. CodeQL on a schedule catches cross-function taint. Run these on AI-heavy branches specifically.
- Spend human attention on the two things scanners miss: authorization logic and trust boundaries. A scanner cannot know that
org_idis your tenancy key. You can. - Ask the model to review itself, then verify. "List the trust boundaries this code crosses and the authz checks at each" produces a useful map, but treat it as a lead, not a verdict. It will miss its own IDORs as readily as it wrote them.
One practical habit: make the PR author annotate where untrusted input enters and where authorization happens. If they can't, that's your finding before you've read a line. AI writes the code; a human still owns the threat model. Keep that line bright and the rest of this gets manageable.
Put this into practice
Work out what an AI model actually costs per month from your token usage, and compare the major models side by side.
Open the AI API Cost Calculator →A note on shelf life. AI products change fast. This guide deliberately focuses on the parts that stay true — how to judge a tool, what the trade-offs are — rather than ranking products that will have changed by the time you read it. Prices and feature claims should always be checked against the provider before you rely on them.