How to review AI-generated code for security
Half of AI-generated samples fail security tests, and scanners miss most of what matters. A concrete review workflow, a worked SQL-injection example, and the checklist to run.
Review AI-generated code the way you would review a pull request from an unknown external contributor: assume it is unverified until a human has read it, find the trust boundaries before reading line by line, and use scanners as a net rather than a verdict. That order matters, because the failures cluster where untrusted input reaches a query, a shell, or a template — and those are exactly the bugs that pass tests and look correct.
The rest of this guide is the concrete workflow, a worked example you can pattern-match against, and the checklist to run on every AI-authored diff.
What the Veracode data really means for review
Veracode's 2025 analysis found 45% of AI-generated samples failed security tests — 72% for Java, and well over a third even in the best-performing languages. The practical translation is simple: a coin-flip failure rate makes "it looks right" an unsafe default. You cannot spot-check AI output the way you might trust a senior colleague's diff, because roughly half of it is carrying something. Review is not a formality here; it is the control.
Where scanners miss
The instinct is to point a SAST tool at the diff and move on. That is necessary but not sufficient. One reported analysis of AI-generated artifacts found that six industry tools combined flagged only around 7.6% of the artifacts and missed the large majority of formally-proven vulnerabilities. Treat that number as a reported figure rather than a hard law — methodologies vary — but the lesson is robust: scanners catch known patterns, and much of what breaks in AI code is context-dependent logic and authorization, which pattern-matchers do not see.
A review workflow that fits AI code
- Assume unreviewed. Treat every AI-authored diff as untrusted until a person has read it. The Veracode failure rate is the justification.
- Map the trust boundaries first. Before reading line by line, find where untrusted input enters (request params, files, third-party responses) and where it reaches a sink (SQL, shell, filesystem, template, outbound HTTP). Injection and authorization bugs live at those crossings.
- Read the security-relevant files closely; skim the boilerplate. Attention is finite — spend it where a bug would be exploitable, not on the DTOs.
- Run an agent reviewer and SAST as a net. GitHub's own guidance for reviewing AI-generated code recommends applying the same scrutiny as any contribution and paying special attention to security-sensitive areas. Automated passes catch the obvious; they do not replace the human read.
- Diff the dependencies. Every new import is new supply-chain surface — confirm each package exists, is the one you meant, and is pinned. This is where slopsquatting defenses belong in the flow.
Worked example: SQL string concatenation
This is the single most common pattern a security reviewer will flag in AI output. The assistant is asked for an endpoint that filters users by role, and it produces something that works on the first request and is exploitable on the second:
// AI-generated: builds the query by string concatenation
app.get("/users", (req, res) => {
const role = req.query.role; // untrusted input
const sql =
"SELECT id, email FROM users WHERE role = '" + role + "'";
db.query(sql, (err, rows) => res.json(rows));
});What the reviewer flags: role is untrusted request input concatenated straight into the SQL string — a textbook SQL injection (CWE-89). A request of ?role=admin' OR '1'='1 returns every row; a more careful payload can read other tables or, depending on the driver, chain a second statement. It passed the developer's manual test because the happy-path input was benign. That is exactly how this class of bug survives to production.
The fix is a parameterized query — the input is bound as a value, never assembled into the statement text:
// Parameterized: the driver binds the value; it is never SQL
app.get("/users", (req, res) => {
const role = req.query.role;
const sql = "SELECT id, email FROM users WHERE role = ?";
db.query(sql, [role], (err, rows) => res.json(rows));
});The tell to internalize: whenever you see untrusted input joined to a query, command, or path with + or template interpolation, stop. Parameterize the query, use the framework's escaping, or validate against an allowlist — but never build the sink from the input.
Layer human, agent-review, and SAST
No single layer is sufficient, and the reported 7.6% figure is the argument for stacking them. Each catches what the others miss:
| Layer | Catches | Misses |
|---|---|---|
| SAST / scanners | Known patterns, some injection, hardcoded secrets | Authorization and logic flaws, novel or context-dependent bugs |
| Agent reviewer | Fast context-aware pass, explains intent, flags smells | Can be confidently wrong; not a substitute for judgment |
| Human review | Trust-boundary, design, and business-logic flaws | Slow, limited attention — needs the layers above to focus it |
The point of the agent reviewer is not to replace the human — it is to do the tedious first sweep so the human's attention lands on the trust boundaries. Claude Code's free /security-review command is a solid starting layer here. The Kaairos Security Kit adds framework-specific reviewers and defense-in-depth hooks on top of it, license-audited and maintained, so the agent pass understands the framework it is reading rather than reviewing generic code. If you want the wider picture on why review is non-negotiable, see the honest answer on whether AI-generated code is secure.
The review checklist
Run this on every AI-authored change. It is deliberately short — the goal is to hit the classes that actually fail, not to audit everything equally.
- Every untrusted input reaches its sink through parameterization, escaping, or allowlist validation — never string concatenation.
- Authentication and authorization checks are present on every protected path, not just the happy path.
- No secrets, tokens, or credentials are hardcoded (see stopping AI from leaking secrets).
- Errors fail closed — nothing sensitive leaks in messages or logs, and no access is granted on the exception path.
- Every new dependency exists, is the intended package, and is pinned to a version.
- Cryptography uses vetted libraries and standard algorithms, not hand-rolled routines.
- The change does only what the task required — no unrequested network calls, file writes, or scope creep.
The mistake to avoid
Do not let the tool that wrote the code be the only thing that reviews it, and do not let a green scanner run stand in for reading. The failure mode is not a lack of tools — it is trusting any single one of them. Combine a human read of the trust boundaries, an agent reviewer for the first sweep, and SAST for the known patterns, and the coin-flip failure rate stops being a production problem.