How to Security-Review AI-Generated Code
AI-generated code does not fail in new ways. It fails in the usual ways, faster, and in places a reviewer has not read.
Start from the right premise
“Written by AI” is not a vulnerability class. Generated code fails in the same ways hand-written code fails: an authorization check that is present on one route and absent on another, a price the server trusts from the client, a fetch that follows a redirect it never revalidates. Treating it as an exotic new category leads to the wrong review.
Two things are different, and they are both about volume and attention. There is more code per hour than a reviewer has historically had to read, and much of it looks idiomatic — which is exactly the property that makes a missing check hard to see. The related argument, that a control can be the right shape and the wrong property, is on the AI-generated code security page. This page is the practical order.
A review order that finds things
Reviewing in file order finds whatever happens to be near the top. Reviewing in authority order finds the things that matter:
1. Authorization who may act on this object, on every path that reaches it
2. Identity and price which request fields the server re-derives instead of trusting
3. Business invariants what must stay true across a state machine, and where it is enforced
4. Outbound requests which URLs the app fetches, and what validates them after a redirect
5. Inbound files what an upload is trusted to be, and where it is stored and served
6. Secrets what is in the repository, the client bundle, and the logs
7. Supply chain what was added, by whom, and whether the lockfile agrees
8. Agent surfaces which tools exist, who may call them, and what text reaches the model1 · Authorization, on every path
The recurring shape is not a missing check. It is a correct check on the path a developer was thinking about, and no check on a second path that reaches the same object — a list endpoint that scopes by tenant and an export endpoint that re-fetches by primary key.
- Enumerate every route that can return or mutate an object, not just the obvious one. Exports, webhooks, admin tools, background jobs and cache-warmers all count.
- Check the object-level decision, not only the authenticated-user decision. "Is logged in" is not "may act on this record".
- Where a database policy is the control, confirm the connection actually carries the identity the policy reads. A row-level policy on a connection that never sets the tenant is decoration.
Depth on this class: BOLA and IDOR review.
2 · Client-controlled fields
Anything the client sends is a request, not a fact. The fields that cause real incidents are the boring ones:
| Field | What the server must do |
|---|---|
| user_id / tenant_id / account_id | Derive from the session. Never read from the body, never trust a header. |
| price, amount, currency, quantity | Re-price server-side from the catalog and the cart. The client sends intent, not totals. |
| role, plan, is_admin, permissions | Load from storage. A submitted role is an escalation attempt with good manners. |
| status / state transitions | Validate against the allowed transition, not merely against the enum. |
| redirect_uri, next, return_to | Allowlist. An open redirect is a phishing primitive and an OAuth problem. |
3 · Business invariants
Generated code implements the happy path well and the invariant rarely, because the invariant is usually not written down anywhere the model could read it. A voucher that may be used once, a refund that may not exceed the capture, a seat that may be held by one booking: each needs a place where the rule is enforced, and that place is almost always the database.
Check-then-act is the tell. If the code reads a row, decides, and then writes, ask what happens when two requests interleave — and look for the unique constraint or conditional update that closes the window. Depth: business logic security testing.
4 · Outbound requests and SSRF
Any feature that fetches a user-supplied URL — link previews, webhooks, avatar imports, PDF renderers, “import from URL” — is an SSRF question. The validation is usually present and usually insufficient:
- Validate after every redirect, not only on the first URL. A 302 to 169.254.169.254 defeats a first-hop check.
- Deny by allowlist of hosts and schemes rather than by blocklist of addresses. Blocklists miss IPv6, decimal notation, and the second private range someone forgot.
- Resolve and pin the address you validated, or you have validated one name and connected to another.
5 · Uploads and parsers
- Type
- Decide from content, not from the filename or the client-sent Content-Type. Then decide again on the way out.
- Location
- Store outside the web root, or in a bucket that does not execute. A path built from a user-supplied name is a traversal.
- Serving
- Serve with an explicit Content-Type and Content-Disposition. An HTML file served as HTML from your origin is stored XSS.
- Parsers
- Archives, images, XML and spreadsheets are all parsers. Bound size, entries and recursion, and disable external entities.
6 · Secrets, in three places
Look in the repository, the client bundle, and the logs. The second is the one AI-assisted work gets wrong most: a key placed in a client-visible environment variable because the build succeeded and the feature worked. A prefix that exposes a variable to the browser is a publication decision, not a configuration detail.
Rotation matters more than removal. A secret that was committed is compromised even after the commit is rewritten, because the object may already be cloned, cached, or indexed.
7 · Supply chain
Generated code introduces dependencies quickly and confidently, occasionally ones that do not exist yet — which is the entire premise of dependency-confusion squatting. Check that every added package is one you meant, that the lockfile agrees with the manifest, and that install scripts are not doing work you did not ask for.
This is the class where a scanner beats an agent outright. Run the scanner; use the review for whether the dependency should be there.
8 · Agent and MCP integrations
If the application itself calls a model with tools, the security boundary moved. Two questions carry most of the risk: which text reaches the instruction channel, and what a tool is permitted to do once the model names it.
- Retrieved documents, ticket bodies and web pages are untrusted input. Concatenating them into the prompt makes them instructions.
- A capability token that binds a run and a tool but not the arguments is not an authorization decision.
- Read-only and privileged tools should not share one dispatch table with one permission check.
Depth: MCP security audit.
Then prove the fix
The step that gets skipped is the one that makes the rest durable. A fix asserted is not a fix demonstrated. Write the assertion so it fails against the vulnerable control and passes after the change:
# The assertion has to fail before the fix and pass after it.
# If it passes against the vulnerable control, it proves nothing.
def test_export_is_tenant_scoped(client, tenant_a, tenant_b, report_of_b):
client.login(tenant_a)
response = client.get(f"/reports/{report_of_b.id}/export")
assert response.status_code == 404If it passes before the fix, it is testing something else. More on this pattern: security regression testing.
What this page does not claim
To run this order with an agent rather than by hand, install the AppSec agent:
npx skills@latest add omarmohelal/SecHelix --skill sechelixApache-2.0, source at github.com/omarmohelal/SecHelix.