Security mistakes agents make repeatedly
The same short list turns up in agent-written code every time, and all of it passes your tests. Here is the list, with the fixes.
The security problems in agent-written code are not exotic. It is the same short list, over and over: a route that checks you are logged in but not that the record is yours, a price read from the request body, a key hardcoded "just for testing". All of it passes your test suite, because a test that asserts the endpoint returns an invoice does not assert that it refuses somebody else's.
Three things explain most of it. Training data is full of tutorial-shaped code, and tutorials skip authorisation because it obscures the point. The agent optimises for the request succeeding, and every security control makes requests fail. And the failures are silent: the feature works, the test is green, nothing goes red. So you look for them deliberately.
Authenticated but not authorised
This is the one, by a distance. Ask for a route that returns an invoice and that is what you get.
// wrong: any logged-in user can read any invoice by guessing an id
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
The auth middleware is right there, which is what makes it look finished. It answers "is this someone" and never "is this theirs". Scope the query rather than checking after it:
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, organisationId: req.user.organisationId },
});
if (!invoice) return res.status(404).json({ error: "Not found" });
Scoping beats a separate ownership check, because a later refactor can drop an if but not a where. Return 404 rather than 403 so the endpoint does not confirm the id exists. And unguessable is not unauthorised: UUIDs leak through shared links, logs and support tickets.
Input the agent will happily trust
Anything in the request body is attacker-controlled. Anything at all.
// wrong: role and price come from the client
const { userId, role, pricePence } = req.body;
await db.order.create({ data: { userId, pricePence, status: "paid" } });
// right: identity from the session, price from your own catalogue
const product = await db.product.findUniqueOrThrow({ where: { id: req.body.productId } });
await db.order.create({
data: { userId: req.user.id, pricePence: product.pricePence, status: "pending" },
});
The wrong version is convenient: the client knows the price already, so passing it saves a lookup. It also lets anyone buy anything for a penny.
The neighbouring failure is no validation at all, so an unbounded array or a 400MB upload reaches your handler.
const Body = z.object({
title: z.string().min(1).max(200),
tags: z.array(z.string().max(40)).max(20),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "Invalid body" });
Size limits count as validation. Every upload endpoint needs a maximum byte count and an allowed content type, enforced server-side.
Where secrets end up
Three predictable places. Hardcoded in source, usually with a comment saying it is temporary. In logs, because dumping the whole request is the fastest way to debug an integration. And in the client bundle, because a variable got a public prefix to make it readable from the browser.
rg -n 'sk-|AKIA|-----BEGIN( RSA)? PRIVATE KEY' -g '!node_modules'
rg -n 'NEXT_PUBLIC_|VITE_|PUBLIC_' src | rg -i 'secret|token|key|password'
rg -n 'apiKey|api_key|password' -g '*.{ts,tsx,json}' -g '!*.test.*'
gitleaks detect
The logging one:
// wrong: dumps Authorization headers and any token in the payload
logger.info("auth callback", { headers: req.headers, body: req.body });
// right
logger.info("auth callback", { userId: req.user.id, provider });
If a secret did reach a commit, rotate it. Removing the line does not remove it from history, and rewriting history does not remove it from anyone's clone.
Small edits with a large blast radius
Four one-line changes that look like nothing.
Raw SQL. The ORM escapes for you, right up until one query needs something it cannot express. The code drops to raw, and the interpolation habit comes with it.
// wrong
await db.$queryRawUnsafe(
`SELECT * FROM invoices WHERE org_id = '${orgId}' AND status = '${status}'`
);
// right: parameterised
await db.$queryRaw`SELECT * FROM invoices
WHERE org_id = ${orgId} AND status = ${status}`;
CORS with credentials. Reflecting the request origin while allowing credentials means any site can make authenticated requests as your logged-in user.
// wrong
app.use(cors({ origin: true, credentials: true }));
// right
app.use(cors({ origin: ["https://app.example.com"], credentials: true }));
TLS verification turned off to make a local certificate error go away. NODE_TLS_REJECT_UNAUTHORIZED = "0" silences the error and disables certificate checking for the whole process. Grep for it before every merge.
Stack traces in responses. Handy in development, a free map of your dependencies and file layout in production. Log the error against a request id and return the id alone.
Hand-rolled auth details
When auth mechanics get implemented directly rather than through a library, the shape is right and the details are wrong. Decoding a token instead of verifying it is the classic: decode reads the payload and checks nothing, so a forged token with "role": "admin" sails through.
// wrong
const payload = jwt.decode(token) as { sub: string; role: string };
// right
const payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
Pin the algorithms. Verification that accepts whatever the token's header declares can be talked out of verifying. Comparing secrets with === leaks length and content through timing, so use a constant-time comparison for webhook signatures and API keys.
import { timingSafeEqual } from "node:crypto";
const a = Buffer.from(provided);
const b = Buffer.from(expected);
const ok = a.length === b.length && timingSafeEqual(a, b);
Also check that expiry is validated rather than merely present, that passwords use bcrypt, scrypt or argon2, and that session tokens come from a cryptographic random source.
Permissions loosened to make something work
The most dangerous category, because it never looks like a security change. It looks like a fix. The query returns nothing, so row-level security gets switched off. The upload 403s, so the bucket becomes public. The deploy fails on a permissions error, so the policy gets a wildcard action.
-- what "the query returns no rows" turns into
ALTER TABLE invoices DISABLE ROW LEVEL SECURITY;
-- what it should have been
CREATE POLICY invoices_own_org ON invoices
FOR SELECT USING (org_id = current_setting('app.org_id')::uuid);
Hunt for these in the diff: tests going green is precisely the symptom. Anything that removes a constraint, widens a policy or makes a resource public deserves a full stop and an explanation. Reading agent diffs in a deliberate order is covered in how to review code you did not write.
Defence that holds up
Three layers, in increasing order of reliability. First, state the rules as absolutes in whatever file your tool loads at session start: CLAUDE.md, AGENTS.md, a Cursor rules file, .github/copilot-instructions.md. Absolutes survive paraphrasing better than guidance does.
## Security rules (non-negotiable)
- Every query in a route handler is scoped by the caller's organisation.
Auth middleware is not authorisation.
- Identity, role, tier and price come from the session or the database,
never from the request body.
- Raw SQL is parameterised, never interpolated.
- No secret in source, in a log line, or behind a public env prefix.
- Never disable TLS verification, RLS or CORS checks to fix an error.
- Every endpoint validates its body and bounds its input size.
Then run a dedicated review before merge, in a fresh session so nothing is being defended:
Review the diff against main for security only. For every route added or
changed, state who can call it, whose rows it can return, and the line
that enforces that. Then list every place user input reaches SQL, a file
path, a shell command or an outbound URL.
And put the mechanical parts in CI, where they run whether anyone remembers or not:
- name: Secret scan
run: gitleaks detect
- name: Dependency audit
run: npm audit --audit-level=high
- name: Static analysis
run: npx semgrep --config auto
Instructions get forgotten as a session fills up. A CI job does not.
The habit that catches most of this takes one sentence per endpoint. For every route you add, answer out loud: who can call this, and whose data can they reach. If the honest answer is "any logged-in user" and "anyone's", you have found today's vulnerability before it shipped.
Keep reading
Debugging an AI-generated codebase you have never read
A repeatable order of operations for fixing code you never wrote, from reproducing on demand to finding the seams where generated code goes quiet.
How to review code you did not write
An ordered pass for large agent-written diffs, so your attention lands on the parts that can actually hurt you.
Tests are how you go faster with an agent, not slower
Tests are what let the agent self-correct without you in the loop, turning ten human turns into one review.