A security checklist for AI-written pull requests

$ cat blog/reviewing-ai-written-pull-requests.md
✓ vibe coding
✓ AI-generated code
✓ code review
✓ checklist
8 min read
$ 

An AI-written pull request tends to look finished. The diff is large, the naming is tidy, the comments are confident and the tests pass. That's exactly why reviewers skim it. A human colleague's rough edges tell you where to look; a model's output is uniformly polished, so nothing draws your eye to the one route that forgot to check who is calling.

The fix is to change what you review for. Don't spend your attention on whether the code works (the author, human or not, already ran it). Review for what the model didn't think about, because nobody asked it to. This post is the closing piece of a series on security in AI-built apps, and it turns the earlier posts into a checklist you can keep next to the diff.

pull request diff (illustrative) + app.get("/api/invoices/:id", (req, res) + const total = sum(items) + import { toCsv } from "csv-fast-utils" + <div dangerouslySetInnerHTML={{__html: msg}} /> no auth or owner check new dependency? raw HTML render fine: plain logic
An illustrative PR diff with three lines a reviewer should stop on. Nothing in it fails a test, which is why none of it draws attention.

Why these PRs need a different read

AI tools optimise for "it works". They'll add the endpoint, the form, the dependency and the test, and they'll do it quickly. What they rarely volunteer is the negative space: the check that isn't there, the limit nobody set, the input nobody validated. Those omissions don't break a demo, so nothing pushes back.

Size makes it worse. A 40-file diff invites approving on vibes. So the checklist below is grouped by the kind of change you see in a diff, so you can scan for the trigger ("new route", "new dependency") and then ask the one question that matters.

Routes and actions: who is calling, and whose data is this?

For every new route, server action or API handler, find two things: where identity is established, and where ownership of the specific object is checked. See broken access control and IDOR for the long version.

  • Authentication. Is the caller verified on the server? A hidden button or a client-side redirect is not a check.
  • Object-level authorization. Does the handler confirm this record belongs to this user (or tenant), not just that some user is logged in?
  • Mutations too. Update and delete handlers are copied from read handlers more often than they get their own checks.
// Plausible, working, and wrong: any signed-in user can read any invoice id.
app.get("/api/invoices/:id", requireUser, async (req, res) => {
  res.json(await db.invoice.findById(req.params.id));
});

// Scoped to the caller. A wrong id looks the same as a missing one.
app.get("/api/invoices/:id", requireUser, async (req, res) => {
  const invoice = await db.invoice.findOne({
    id: req.params.id,
    ownerId: req.user.id,
  });
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

If the app talks to a hosted database straight from the browser, the same question moves into policies. That's covered in Supabase and Firebase row-level security.

Inputs: validated on the server

  • Schema validation on the server. Is the request body parsed through a schema (zod, Pydantic, Joi) before use? Client-side validation is a UX feature, not a control.
  • Size and shape limits. Max string length, max array length, max body size, max page size. Unbounded inputs become slow queries and large bills.
  • No pass-through. Be suspicious of db.collection.find(req.body) or spreading a request body into an update, which lets a caller set fields like role or ownerId.
const CreateNote = z.object({
  title: z.string().min(1).max(200),
  body: z.string().max(10_000),
});

const input = CreateNote.parse(req.body); // throws on anything else
await db.note.create({ ...input, ownerId: req.user.id });

Note that ownerId comes from the session, never from the request.

Data access: parameterized and scoped

  • Parameterized queries. Look for string concatenation or template literals inside SQL, and for user objects passed into NoSQL filters.
  • Scoped by default. Does every query include the user or tenant? Models often write the happy-path query first and the scoping never arrives.
  • Least-privilege credentials. Does the new code use an admin or service key where a user-scoped one would do?

Secrets: none added, none exposed

  • Nothing in code, config or tests. Keys pasted "just to get it running" survive in git history long after you delete them. See secrets in git history.
  • No secrets in client-exposed env vars. Variables with a public prefix (NEXT_PUBLIC_, VITE_) are shipped to every visitor. The details are in API keys in frontend bundles.
  • Example env files. New entries in .env.example should hold placeholders only.

Dependencies: do they exist, and do you need them?

Every new line in package.json, requirements.txt or go.mod is code you're about to run with your app's privileges.

  • Does the package exist and is it the one intended? Models sometimes name packages that don't exist, and attackers can register those names. See slopsquatting and hallucinated packages.
  • Is it needed at all? A 10-line helper doesn't need a new dependency.
  • Pinned, with the lockfile updated? A manifest change without a lockfile change is a flag.
  • Install scripts or remote downloads (curl | sh) in the change are worth a second look.

Output: no raw HTML from users or models

  • No raw HTML sinks. Avoid dangerouslySetInnerHTML, innerHTML or v-html on user content, or on model output. Model text can carry markup that came from somewhere you don't control.
  • Markdown renderers should sanitise, or run in a mode that doesn't allow raw HTML.
  • Links and URLs from data should be checked for scheme (javascript:) before going into an href.

Errors and logs

  • No stack traces or raw exceptions to users. Return a generic message and log the detail.
  • No PII, tokens or full request bodies in logs. "Log the request for debugging" is a common line to find in generated code, and logs are often readable by far more people than the database.

Config: cookies, CORS, debug

  • CORS is an explicit allowlist, not * combined with credentials, and not a reflected Origin header.
  • Cookies carry HttpOnly, Secure and a deliberate SameSite value.
  • Debug flags (DEBUG=true, verbose error pages, open admin or docs routes) aren't left on for production.
res.cookie("session", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 60 * 60 * 24 * 7 * 1000,
});

Files and URLs

  • Uploads: type checked by content, not just filename; size capped; stored outside the web root or in private storage; served with a safe content type.
  • Server-side fetches: when the server fetches a URL a user supplied (link previews, webhooks, "import from URL"), is the destination restricted? Otherwise the server can be pointed at internal addresses. See the server-side request forgery agent's scope for what to look for.

Anything that costs money per call, sends email or SMS, or does heavy work needs a limit per user and per IP, plus a spending cap on the provider side. Generated code almost never adds one. Denial of wallet covers how this goes wrong and how to cap it.

Tests: is the security property tested?

Generated tests prove the happy path. Ask whether any test would fail if the security property broke.

it("does not let user B read user A's invoice", async () => {
  const invoice = await createInvoice({ owner: userA });
  const res = await api.as(userB).get(`/api/invoices/${invoice.id}`);
  expect(res.status).toBe(404);
});

One two-user test like this catches the most common authorization bug, and it keeps catching it on every later refactor.

Process: make the review easier before it starts

The checklist works best when the PR is easier to review in the first place.

  1. Ask for smaller PRs. Tell the tool to split big changes: schema first, then routes, then UI. Review is only as good as your attention span.
  2. Ask it to explain its auth model for the change. "For each new route, state who may call it and how ownership is checked." A confident wrong answer is a useful review signal, and a missing answer is a bug.
  3. Keep a rules file. Put your non-negotiables in CLAUDE.md, AGENTS.md or your editor's rules (such as Cursor rules): validate with schemas, scope queries to the user, never add secrets, no raw HTML, add a two-user test for new routes. These files steer the tool but don't enforce anything, so keep the human check.
  4. Require human approval to merge. When an AI agent opens the PR itself, a person reads it before it lands. Branch protection should require this, not just trust.

For the bigger picture, start with why vibe-coded apps share the same security flaws.

How OpenRouting helps

A checklist is only useful if it gets applied to every PR, and big diffs are where attention runs out. OpenRouting's pull-request review reports findings in a PR's changed files and posts a single summary comment, and each finding gets an AI triage verdict, a plain-English explanation and a suggested fix. For this checklist's areas, the access-control, cross-site-scripting and secrets-data-exposure agents each review within their own scope. Read our write-up on fewer false positives from AI code review for how the triage works.

OpenRouting reviews your code, not your runtime: it doesn't enforce rate limits or check your cloud dashboards. Items like limits, cookie behaviour in production and merge approval still need a human and your own settings. Treat the review as a second pair of eyes, not a replacement for the first.

Questions

How should I review an AI-generated pull request for security?

Don't spend your attention on whether the code works; review for what the model didn't think about. Scan the diff for triggers such as a new route, input, query, secret, dependency or output sink, and ask the one security question each one raises.

What should I check on a new route in an AI-written PR?

Find where identity is established on the server and where ownership of the specific object is checked. Confirm the handler verifies the record belongs to this user or tenant, not just that some user is logged in, and check update and delete handlers too.

How do I check new dependencies added by an AI coding tool?

Confirm each package exists and is the one intended, since models sometimes name packages that don't exist and attackers can register those names. Also ask whether it is needed at all, whether it is pinned with the lockfile updated, and whether it adds install scripts or remote downloads.

What test catches the most common authorization bug?

A two-user test: create a record as user A, request it as user B and expect a 404. It fails if the ownership check breaks, and it keeps catching that bug on every later refactor.

Keep reading