Worked example

Spotting a missing tenant check in a pull request

The tell is a new handler that loads a record by id and never asks which organization it belongs to, while its neighbors in the same file do. One diff, the question that settles it, and the one-line fix.

Tristan Roth,

A missing tenant check is a handler that loads a record by the id the caller sent, confirms the caller is logged in, and never confirms the record belongs to the caller's organization. In a pull request, the tell is usually a neighbor: the other handlers in the file go through a helper that scopes by organization, and the new one queries the table directly. The fix is one clause, scope the lookup by the session's organization, and the proof is a test where a member of org B asks for org A's record and gets a 404.

The story behind the diff

A team asks its coding agent for an Archive button on the projects page. The agent reads the file, sees how records are loaded and updated, and writes a clean new route. The tests pass: the test user archives its own project. The pull request is small and well formatted, and it gets approved in a minute.

Tenant isolation is the rule that one customer's data never reaches another customer, in an app where many customers share the same database. Every row carries an organization id, and so does every session. The rule is kept only if every query that uses an id from the request also checks the organization. It takes one query that forgets.

The diff

The existing handlers in this file call loadProjectForOrg, which filters by the session's organization. The new route, added in the pull request, goes to the table directly.

projects routes, the handler the pull request adds
  // Existing handlers: const project = await loadProjectForOrg(req.session.orgId, req.params.id);
+ app.post("/projects/:id/archive", requireSession, async (req, res) => {
+   const project = await db.project.findUnique({
+     where: { id: req.params.id },
+   });
+   if (!project) return res.status(404).json({ error: "not_found" });
+   await db.project.update({
+     where: { id: project.id },
+     data: { archivedAt: new Date() },
+   });
+   res.json({ ok: true });
+ });

The question that settles it

Who names the key, the session or the request? Here the request names it: req.params.id comes from the URL. Then: where is the organization checked? Not in requireSession, which only proves someone is logged in. Not in the query, which filters by id alone. Not in a helper, because the new route skipped the one its neighbors use. So any logged-in member of any organization can archive any project whose id they know or guess.

This is the object-level shape OWASP lists as API1:2023, Broken Object Level Authorization, whose related weaknesses include CWE-639, Authorization Bypass Through User-Controlled Key. It sits inside the broader CWE-284 category, Improper Access Control.

The fix, and the test that keeps it fixed

Use the helper the rest of the file uses, or scope the lookup yourself: replace findUnique with findFirst and add the organization, where: { id: req.params.id, orgId: req.session.orgId }. A project in another organization then looks exactly like a project that does not exist, and the existing 404 becomes the denial. Reusing the helper is the stronger fix, because it keeps the rule in one place for the next route too.

Then write the test the pull request was missing: sign in as a member of org B, archive a project of org A, expect a 404, and check the project is still active. The OWASP entry names both halves: check authorization in every function that uses an input from the client to access a record, and write tests for the authorization mechanism.

What an Aevral comment on this pull request looks like

With the Aevral GitHub App installed, this pull request gets an advisory Check on its head commit, live on install. If Aevral grounds a finding on the added lines, it posts an inline comment on the query: in plain words, any logged-in user can archive another organization's project because the lookup is not scoped to the caller's organization, with the file and lines as evidence and a suggested fix. The comment carries a Fix with your agent prompt a human can copy into Claude Code, Cursor, or Codex. It is a lead, not a verdict, and it never blocks the merge.

The published record: on the Aevral receipts page, the PR review runs of 25 September 2026 read "10 of 12" (Run 1) and "11 of 12" (Run 2) on the frozen authorization slice of planted pull requests. In the page's own words: "Each recall number is the result of that named run on that corpus. It is not a product accuracy rate, and results on your code depend on your code."

From install to the first comment

One: install the Aevral GitHub App on the repositories you choose; a GitHub owner or admin approves the install. PR review is live on install: installing the App starts reviews on the next pull request, even before anyone signs in, and the owner can turn them off in Setup.

Two: sign in to the console with GitHub. That connects the install and starts a 14-day trial for private repositories: private reviews are free up to 500, and the trial ends at 14 days or 500 reviews, whichever comes first.

Three: open a pull request, or let your coding agent open one. Aevral reads the diff plus up to 40 changed files at head.

Four: read the result on the pull request. An advisory Check sits on the head commit, and when there is a grounded finding, inline comments sit on the added lines, at most two findings per review. Each finding carries the evidence, a suggested fix, and a Fix with your agent prompt a human can paste into Claude Code, Cursor, or Codex. Aevral never pushes, applies, or merges anything, and it never blocks a merge. You decide what ships.

Sources

OWASP API1:2023 Broken Object Level Authorization; CWE-639: Authorization Bypass Through User-Controlled Key; CWE-284: Improper Access Control; OWASP Authorization Testing Automation Cheat Sheet; Aevral receipts.

Read next

How IDOR happens in multi-tenant code; Next.js and Supabase: the authorization check before launch; Reviewing a pull request for access control; Install the GitHub App; Pricing.

More guides


Security review, handled.

Install the GitHub App and PR review starts on. Sign in with GitHub to connect it, then press Scan for the repository you already have.

Install the GitHub AppLog in

For professional use. By installing, you confirm you can act for the account or organization that owns it, and you accept the Terms and DPA on its behalf.