AI-generated code fails security review differently than human code does. Human vulnerabilities cluster around fatigue and shortcuts: the check skipped at 6pm, the TODO that shipped. Generated vulnerabilities cluster around a plausible-looking whole with one missing half, and they look finished, which is why they pass the read that catches human mistakes.
The pattern is specific enough to review for directly. A model produces the shape of a secure feature because that shape is well represented in its training data, and it omits the parts that were never visible in the examples it learned from. This guide is the audit pass for exactly that failure class.
Why reading the diff for bugs cannot find what is absent
The default review reads generated code the way you would read a colleague's pull request, looking for things that are wrong. It misses this failure class entirely, because nothing in the diff is wrong. The code present is correct. The vulnerability is code that is absent.
A missing ownership check does not appear in a diff. A validation that exists only in the browser looks like validation. An endpoint with no rate limit looks like an endpoint. You cannot find absences by reading for errors, so the review has to be structured as a checklist of things that must be present, applied against the trust boundary rather than against the file.
Review by asking what must exist here. Never by asking what looks wrong.
By the end you will have
- The seven vulnerability classes that generated code produces most reliably
- The reason each one is produced, so you can predict them in unfamiliar code
- The Trust Boundary Audit
- A grep pass that catches the mechanical subset in seconds
- A rule for what never gets generated at all
The seven classes, and why each one appears
| Class | What it looks like | Why generation produces it |
|---|---|---|
| Client-side-only validation | Length, type, and size checks in the component, none on the route | Tutorials and examples show the UI half. The server half lives in a different file that was never in the same snippet |
| Missing ownership check | Authenticated route that reads an id from the request and returns the record | Authentication is in the training data far more often than authorization. "Is logged in" is conflated with "may access this" |
| Secrets reaching the client | Key read in a component, or a privileged client imported into shared code | Framework boundaries are invisible in a code snippet. The model cannot see which bundle a file lands in |
| Over-permissive defaults | CORS *, wildcard bucket policy, SELECT * returned to the caller | Permissive settings are what makes examples work without setup, so they dominate example code |
| String-built queries and commands | Interpolated SQL, shell commands assembled from input | Concatenation is the most common way to show the idea. Parameterized versions are noisier and rarer |
| No rate limit or spend cap | Any expensive route: auth, email, upload, model call | Limits are infrastructure concerns absent from feature examples |
| Error responses that leak | Raw exception, stack trace, or database message returned to the caller | Verbose errors are correct in the development examples the model learned from |
Read that table as a prediction, not a history. On the next generated feature, those seven are where to look before reading anything else.
Mechanism dive: why the missing half is always the same half
A model completes the most probable continuation of a request. For a feature request, the highest-probability continuation is the code that makes the feature work, because that is what the overwhelming majority of code in the corpus does. Security controls are, in the corpus, the minority case: often in a different file, often in a middleware layer, often omitted from the example entirely to keep it readable.
So the model is not making a security judgment and getting it wrong. It has no security judgment in play. It produces the working half at high confidence, and the protecting half appears only when it happened to co-occur with the working half in enough examples.
This predicts the distribution precisely. Controls that live in the same function as the feature get generated reliably: a null check, a try/catch. Controls that live at a boundary, in another file, or in infrastructure get omitted reliably: authorization, rate limits, bundle boundaries, CORS. The further a control sits from the code that implements the feature, the less likely generation is to include it.
That is why the audit is organized around trust boundaries rather than around files. The boundary is exactly where the model's context ended.
Grep the mechanical subset first
Some of this is text matching and takes seconds. Do it before any human reading.
# secrets or privileged clients in client-reachable code
rg -n "process\.env\.[A-Z_]*(SECRET|KEY|TOKEN|PASSWORD)" src/components src/app --glob '!**/*.server.*'
rg -ln '"use client"' src | xargs rg -n "SERVICE_ROLE|ADMIN_KEY|SECRET"
# string-built queries and commands
rg -n "(query|execute|raw)\(\s*[\`\"'].*\\\$\{" src
rg -n "exec\(|execSync\(|child_process" src
# permissive defaults
rg -n "Access-Control-Allow-Origin.*\*|cors\(\)" src
rg -n "eq\(.*id.*\)" src/app/api # ids from request, check each for an ownership predicate
# error leakage
rg -n "res\.(json|send)\(.*(err|error|e)\b" src
rg -n "catch.*\{\s*.*(message|stack)" srcEvery hit is a candidate, not a finding. The value is that it produces a short, ranked list of places to actually read.
The worked example: an upload route that reviewed clean
A file upload generated in one request. The component validated type and size, showed progress, and handled errors. The route stored the file and returned a URL. It worked, and a normal read found nothing wrong, because nothing in it was wrong.
The boundary audit found three absences in under ten minutes. The size limit existed only in the component, so any direct request to the route could post a file of any size. The route authenticated the caller but never checked that the target folder belonged to them, so a modified id wrote into another account's namespace. And the storage error was returned verbatim, which disclosed the bucket path and the provider.
None of these are subtle once you look for them. All three survived a careful read of the diff, because a careful read of the diff is the wrong instrument. They were found by asking what must exist at the browser-to-server boundary and then noticing it was not there.
Magnet: Trust Boundary Audit
Run per feature, not per file. Identify each boundary the feature crosses, then answer every question at that boundary.
## Boundary 1: browser → server
- [ ] Is every client-side validation duplicated on the server? (size, type, length, range, enum)
- [ ] Is the identity derived from the session on the server, never read from the request body?
- [ ] Are ids from the request checked for ownership, not just for existence?
- [ ] Are prices, roles, quantities, and permissions re-derived server-side?
- [ ] Is there a rate limit on this route? Name it. "The platform has one" is not an answer.
## Boundary 2: server → data
- [ ] Are all queries parameterized? Zero interpolation into SQL or command strings.
- [ ] Does the query filter by owner, or does it filter after fetching?
- [ ] Are row-level policies on, and does this path actually go through them?
- [ ] Does the response return only the fields the caller needs?
## Boundary 3: server → client bundle
- [ ] Does any secret, service key, or privileged client reach a client component?
- [ ] Is anything sensitive in a shared module that both sides import?
- [ ] Do error responses contain provider messages, stack traces, or internal paths?
## Boundary 4: system → outside
- [ ] Is CORS scoped to known origins, not `*`?
- [ ] Are storage and bucket permissions least-privilege?
- [ ] Is there a spend cap on every paid call this route can trigger?
- [ ] Are webhooks signature-verified before any side effect?
## Boundary 5: agent → system (if an agent can call this)
- [ ] Is destructive capability gated behind explicit approval?
- [ ] Are inputs from fetched or tool-returned content treated as untrusted?
- [ ] Is there a spend and blast-radius limit independent of the prompt?You should see: at least one unchecked box per generated feature, and it will usually be in boundary 1 or 3. A feature that passes every box on the first run means either the generation was unusually well scoped, or the audit is being answered from memory rather than from the code. Answer each box by pointing at the line that satisfies it.
Failure modes
| Smell | Result | Repair |
|---|---|---|
| Reviewing the diff for things that look wrong | Absences are invisible | Checklist of what must be present, per boundary |
| Reviewing file by file | Boundaries fall between files and go unreviewed | Audit per feature, following the request path |
| "It is authenticated" as the answer to authorization | Any logged-in user reaches any record | Ownership predicate on every id from the request |
| Asking the model to review its own output | It reproduces the same blind spot | Fresh context, checklist supplied, or a human |
| Trusting the platform for rate limits | Discovered during the incident | Name the limit and the number |
| Scanner passes, so it is fine | Static analysis finds injection, not missing authorization | Scanners plus the boundary audit, never one |
| Audit run before merge only | Later generated edits reopen it | Re-run when the boundary changes |
When not to generate the code at all
Some code should be written by hand and reviewed by the model, rather than the reverse.
- Authentication and session handling. Small, high-blast-radius, and the failure is silent.
- Authorization logic itself. The policy layer cannot be checked by the same process that keeps omitting it.
- Money movement. Payments, refunds, balance changes, anything with an amount and a direction.
- Cryptography. Use a vetted library and read its documentation. Never generated, never hand-rolled.
- Migrations and destructive operations. Wrong is unrecoverable and review happens after the fact.
- Anything an agent can call unsupervised. Write the gate yourself, then let generation build behind it.
path
Put the audit in the pipeline
Agent OS Setup installs boundary checks, permission limits, and verification gates so this runs on every change instead of when someone remembers.
Generated code is rarely wrong. It is frequently incomplete, in the same place, for a reason you can predict.
Your next action: take the last feature you shipped that a model wrote, run the Trust Boundary Audit against it, and answer every box by pointing at a specific line. The boxes you cannot point at are your findings.
Related: Production Checklist for a Vibe-Coded Web App covers the release gate this audit feeds, and Install Agent Skills Without Getting Owned covers the supply-chain side. The audit is one gate of six: what an agent OS is maps the rest, and the Release Receipt Kit packages the release half as a local CLI.

