Review & Verify Updated 2026-09 10 min read View as Markdown

Security review checklist for AI-generated Python

Generated code is not more malicious than human code. It is more confidently insecure, in a small number of predictable ways, at higher volume.

Two things are true at once. Coding agents write more secure code than the average tutorial they learned from — they use parameterised queries by default, they hash passwords properly, they do not roll their own crypto. And they introduce a specific, repeatable set of vulnerabilities at a volume that human review does not scale to.

The second is the operative one. Your review process has to be mechanical, because your attention is the resource that ran out.

Turn on the machine checks first#

Before any of the manual review below, get the automated coverage. Most of what follows is caught for free.

shell
uv add --dev ruff pip-audit
pyproject.toml
[tool.ruff.lint]
select = ["S", "B", "BLE", "ASYNC", "E", "F"]   # S = bandit ruleset
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S105", "S106"]
shell
uv run ruff check .        # every edit, via a hook
uv run pip-audit           # in CI

Add secret scanning to pre-commit (gitleaks, detect-secrets) and you have covered the majority of what actually goes wrong.

The manual checklist#

Injection: is any input reaching a shell, a query, or a template unparameterised?#

shell
git diff | grep -nE "shell=True|os\.system|f\".*SELECT|f\".*INSERT|eval\(|exec\("

The recurring Python instances:

  • subprocess.run(f"...", shell=True) — pass a list, leave shell=False.
  • f-string SQL where the surrounding code has no ORM. Parameterise. Always.
  • eval() / exec() on anything derived from input. There is no safe version of this.
  • Jinja templates rendered from user-controlled strings — Template(user_input) is remote code execution, not templating.

Deserialisation: what is being loaded, and from where?#

pickle.loads, yaml.load without SafeLoader, marshal, dill, and torch.load on an untrusted checkpoint all execute arbitrary code by design. Generated caching, config-loading and model-loading code reaches for these constantly.

Rule: if it crossed a trust boundary, it is JSON.

Secrets: is anything hardcoded, logged, or in an error path?#

shell
git diff | grep -niE "api_key|secret|token|password|bearer|BEGIN .*PRIVATE KEY"

Two specifically generated patterns worth naming:

python
# 1. the "example" that ships
API_KEY = os.getenv("API_KEY", "sk-test-abc123")     # real key as a default

# 2. the debug log that survives
logger.info("calling %s with %s", url, headers)      # headers include Authorization

The second is the one that gets missed, because it is not in the diff as a secret — it is in the diff as a log line.

Authorisation: is it checked, and is it checked in the right place?#

Generated endpoints frequently authenticate — is this a valid user — and forget to authorise — is this their resource.

python
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, user: User = Depends(current_user)):
    return await db.get_invoice(invoice_id)     # any logged-in user, any invoice

This is the single most common real vulnerability in generated web code, it is invisible to every linter, and the test that catches it is one line:

python
async def test_cannot_read_another_users_invoice(client, alice, bobs_invoice):
    r = await client.get(f"/invoices/{bobs_invoice.id}", headers=alice.auth)
    assert r.status_code == 404      # not 403 — do not confirm existence

Write that test for every resource-scoped endpoint. It is the highest value-per-line test in a web codebase.

SSRF: does anything fetch a URL that came from outside?#

python
@router.post("/import")
async def import_from_url(url: str):
    r = await client.get(url)        # now fetches your cloud metadata endpoint

Generated "import from URL", webhook and avatar-fetching features almost never validate the target. Allowlist schemes and hosts, resolve and check the IP, and disable redirects.

Crypto and randomness#

  • random for tokens, session ids, password resets, filenames. Use secrets.
  • md5/sha1 for anything security-relevant.
  • Hand-rolled comparison of secrets — use hmac.compare_digest.
  • Encryption without an authenticated mode. If you are choosing a cipher mode by hand, stop and use cryptography's recipes layer.

Errors and information disclosure#

debug=True, full tracebacks returned to clients, exception messages that include the SQL, the file path or the internal hostname. Generated error handlers are helpful to a fault.

The one that is new: prompt injection#

If the Python you are writing is an LLM application, there is a class of bug that no linter has heard of.

Any text your application feeds a model — a scraped page, a user message, a PDF, a database row someone else wrote, an MCP tool result — can contain instructions. If your model has tools, those instructions can be actions.

python
# every one of these is untrusted input, not data
context = fetch_page(url)
context += load_pdf(upload)
context += db.query("SELECT bio FROM users WHERE ...")
answer = await agent.run(system=SYSTEM, context=context, tools=[send_email, query_db])

The mitigations that hold up:

  • Least privilege on tools. The blast radius of an injection is exactly the set of tools available. A read-only agent cannot be made to exfiltrate.
  • Confirm side effects. Anything that sends, pays, deletes or publishes goes through a human, or through a deterministic check the model cannot argue with.
  • Separate the channels. Untrusted content goes in a clearly delimited region, never concatenated into the system prompt.
  • Constrain the output. If the model's job is to return a category, validate it against an enum in Python. Do not let free text become control flow.
  • Watch the combination. Fetching untrusted content and having a write tool in the same session is the dangerous configuration. Either alone is usually fine.

There is more on the testing side of this in testing LLM-powered Python.

The review, as a command#

shell
git diff | grep -nE "shell=True|eval\(|exec\(|pickle|yaml\.load\(|md5|sha1\(|random\.|verify=False|debug=True|except Exception"
git diff | grep -niE "api_key|secret|token|password|BEGIN .*PRIVATE"
uv run ruff check . && uv run pip-audit

Three commands, thirty seconds, and it catches most of this page. The two it cannot catch — missing authorisation and prompt injection — are the two worth spending your actual attention on.

Common questions#

Is AI-generated code less secure than human code?#

Mixed, and the framing is not very useful. On the basics it is often better than the median human code, because it defaults to parameterised queries and proper password hashing. On authorisation logic and on anything requiring knowledge of your trust boundaries, it is worse — and it produces code at a volume that overwhelms the review process that used to catch these things.

What is the single highest-value check?#

For a web application, the authorisation test: for every endpoint that returns a resource, one test asserting that a different user gets a 404. No static analysis finds this class of bug, it is the most common real vulnerability in generated web code, and the test is one line.

Does prompt injection apply if my app only summarises text?#

Yes, but the impact is small — the worst case is a bad summary. The risk scales entirely with what the model can do. Summarisation with no tools is close to harmless; summarisation with an email tool attached is a data exfiltration channel.

Do I need a paid security scanner?#

For a small team, ruff's bandit rules plus pip-audit plus secret scanning in pre-commit covers most of what a paid tool would flag, at no cost. The gap that paid tools fill is cross-file dataflow analysis, which starts to matter on large codebases with many contributors.

Get the Python agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Python. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.

Disclosure: some links on this page are affiliate links. If you buy something through one, we earn a commission at no extra cost to you. We only list tools we would tell a friend to use, and we say so when we have not used something ourselves. This is how the site stays free and ad-light.