Why AI-Generated Code Needs a Security Pass

AI assistants write code that looks finished — and that polish is exactly what lowers a reviewer's guard. The blind spots I keep finding in generated code, and a routine that catches them.

blind spotsreviewed
Rick
Senior Security Engineer
Jun 16, 2026
6 min read

I reviewed two pull requests back to back last month. The first was from a junior engineer: slightly awkward naming, a TODO comment, one clumsy conditional. The second was largely AI-generated: clean structure, consistent style, docstrings on everything, even sensible-looking tests. Guess which one contained an endpoint that let any authenticated user read any other user's documents.

It was the polished one. And that's not an anecdote about AI being bad at code — the generated code was, line by line, better written than the junior's. It's an anecdote about what polish does to reviewers. We've spent our careers using surface quality as a proxy for care: messy code gets scrutiny, clean code gets trust. AI assistants break that proxy. They produce code with senior-level finish and no security judgment at all, and if our review instincts don't adapt, the finish walks the flaws right past us.

Why the blind spots are systematic

It helps to be precise about why generated code has security gaps, because the gaps aren't random — they follow directly from how these tools work, which means you can predict where to look.

An assistant produces the most plausible code for your prompt, where "plausible" is shaped by its training data: decades of tutorials, documentation examples, and public repositories. Three consequences fall out of that.

Tutorials omit security on purpose. Example code is written to teach one concept with minimum noise, so authentication, authorization, rate limiting, and input validation get stripped out — "left as an exercise." The corpus is systematically biased toward code with security removed, and the model learned that this is what code looks like.

Your context isn't in the prompt. Your tenancy rules, your policy layer, your "we never permit role" convention — none of that is visible to the assistant unless someone spells it out every time. So the output is generically reasonable and locally wrong, in exactly the ways your house rules exist to prevent.

Nothing the assistant learned expires. Cryptographic advice, especially, rots — and the corpus contains twenty years of it, most confidently written, much of it now wrong. The model has no native sense of which decade an idiom belongs to.

Each of these produces a signature blind spot. Let me walk the big ones.

The recurring finds

Authorization that simply isn't there

The number one issue, by a wide margin. Ask for "an endpoint to fetch a document by ID" and you'll get exactly that:

@app.get("/documents/{doc_id}")
def get_document(doc_id: int, user: User = Depends(current_user)):
    doc = db.get(Document, doc_id)
    if not doc:
        raise HTTPException(404)
    return doc

Authenticated, validated, error-handled — and any logged-in user can read every document in the system, because ownership wasn't in the prompt and isn't in most training examples. The vulnerability is an absence, which is the hardest thing to see in a diff: there's no bad line to point at, and every line that exists looks professional. The fix is one clause — db.get(Document, doc_id) becomes a query scoped to what the user can access — but someone has to notice it's missing.

Plausible-but-unsafe defaults

Generated glue code is full of settings that make things work in a demo and fail in production: CORS opened to * with credentials allowed, verify=False on TLS calls "to fix the certificate error," debug mode on, pickle or YAML load on untrusted input, tokens stored in localStorage because that's what a thousand tutorials did. Each is the most common answer to the immediate problem, which is precisely why the model reaches for it. In review, config and initialization code in generated diffs deserves a slow read — it's short, it's boring, and it's where the demo defaults hide.

Outdated crypto and security idioms

This is where the frozen-corpus problem bites hardest. I still see generated code hashing passwords with SHA-256 (or MD5 on a bad day), using ECB mode, seeding tokens from non-cryptographic randomness, or hand-rolling JWT validation with the algorithm taken from the token header. The code often cites its own era if you know the tells — Random instead of secrets, deprecated OpenSSL calls, md5 in a variable name.

# Plausible, and wrong since before some of your users were born
password_hash = hashlib.sha256(password.encode()).hexdigest()
# What current actually looks like
from argon2 import PasswordHasher
password_hash = PasswordHasher().hash(password)

The general rule: generated cryptographic code should never be accepted on plausibility. Either it matches your platform's current first-party guidance, verified by a human against live documentation, or it gets replaced by the framework's built-in (which someone else already got right).

Confident handling of the wrong risk

A subtler one: assistants are great at visible robustness — try/except everywhere, input length checks, retries — and this creates a halo. The code clearly considered failure, so surely it considered attackers? No. Defensive-looking code and secure code are different properties. I've seen generated handlers validate a payload's schema meticulously and then interpolate one of its fields into a shell command. The schema check was plausible; the injection was too.

A review routine that actually catches this

The answer is not "don't use assistants" — that ship has sailed, and honestly the productivity is real. The answer is a review posture calibrated to the failure modes. Mine looks like this:

Review the absences first. Before reading the code that exists, ask what should exist: Where's the authorization check? The tenant scope? The rate limit on this auth endpoint? Generated code fails by omission more than by commission, so start from a checklist of what the endpoint class requires, not from line one of the diff.

Distrust polish explicitly. Say it to yourself if you have to: finish quality is not evidence of security. Give clean generated code the same scrutiny you'd give a first-week hire's — friendly, thorough, assuming nothing.

Slow down on config, crypto, and anything interpolated. Three high-yield zones: initialization/settings blocks (demo defaults), anything cryptographic (frozen-corpus rot), and any place a runtime string meets an interpreter — SQL, shell, HTML (tutorial-grade injection).

Make your context machine-visible. The durable fix is moving house rules out of heads and into things that constrain generation and review alike: linters that flag unscoped queries, policy layers that raise when skipped, CI secret scanning, tests that probe endpoints as the wrong user. Every rule you encode is a rule the assistant can't silently skip and a reviewer doesn't have to remember.

Questions I ask of every generated diff

  • Who is allowed to call this, and what line enforces it? (No line, no approval.)
  • Would this behave in production the way it behaves in a demo — CORS, TLS verification, debug flags, token storage?
  • Is any crypto here verified against current guidance, or is it just plausible?
  • Where does user input travel, and does it ever get promoted from data to code?
  • If I'd received this exact diff from an unknown contributor, what would I check? Go check that.

AI assistants have changed the distribution of bugs more than the total: fewer typos and null derefs, more confident, well-formatted omissions of the security layer. That's actually a trade reviewers can win — omissions are predictable, and predictable means checkable. But only if we stop grading code on how finished it looks, and start grading it on what it forgot.

ai-generated-codecode-reviewsecure-defaultsauthorizationcryptography
Written by
Rick
Senior Security Engineer · Firetrail review team
More Security