A missing authorization check rarely looks like a bug. It looks like a function that does slightly less than the one next to it. Two request handlers sit in the same file, both load a record by id, both return it as JSON. One compares the record to the caller first. The other does not. To a text search they are the same three lines. To the person reading a 400-file service at 5pm, they are the same too.
Two handlers that look identical
Here are two Express handlers from the same controller. Read them the way grep reads them.
async function getInvoice(req, res) {
const tenantId = req.user.tenantId;
const invoice = await db.invoices.findById(req.params.id);
if (invoice.tenantId !== tenantId) return res.status(403).end();
return res.json(invoice);
}
async function getDocument(req, res) {
const doc = await db.documents.findById(req.params.id);
return res.json(doc);
}
getDocument takes an id from the URL, hands it to findById, and returns whatever comes back. It never reads req.user. Any authenticated caller who changes the id in the path reads another tenant’s document. That is a broken object level authorization bug, the class most people still call IDOR, and it is the single most common finding in access-control review for a reason: nothing about the code looks wrong.
Why grep and a symbol index both miss it
Search the repo for findById and you get both handlers. Both are real call sites. Both take a request-controlled id. A symbol index, the thing your editor’s “go to definition” runs on, tells you the same story: two references to the same method, same shape, same types. LSP, ctags, and SCIP all answer the question where does this name appear. That question cannot separate these two functions, because the difference between them is not a name. It is an access control check that one function runs and the other skips, and a check that is absent leaves nothing for a text search to find.
This is the gap a code graph closes over grep. The bug is not in what the code says. It is in what the code does with a value.
Follow the value, not the name
Trace the request through getInvoice as a value, not as text. The caller’s tenantId comes off req.user. It reaches a comparison, invoice.tenantId !== tenantId. That comparison decides whether the response is the record or a 403. So the identity of the caller reaches a guard, and the guard dominates the return. The record only leaves the building after the check clears.
Now trace getDocument. req.params.id reaches findById. The returned doc reaches res.json. Nothing off req.user reaches anything between the lookup and the response. The path from a request-controlled value to the returned object has no guard on it.
Stated that way, the two functions are not similar at all. One has a source-to-sink path that passes through an ownership check. The other has the same path with the check missing. That property lives in data flow, in how the value moves, and it is invisible to anything that reads the code as a bag of names. This is the same machinery taint analysis uses to follow untrusted input to a dangerous sink, pointed at authorization instead of injection.
Doing it with a code graph
A code property graph stores syntax, symbols, calls, and the dataflow layer in one queryable structure, so “does a request value reach this sink without passing a guard” is a query you run, not a review you do by eye. Lachesis is an open-source one for C, Python, and TypeScript. Install it and build a graph from the bundled fixture that carries these exact two handlers.
python -m pip install lachesis-cpg
lachesis-analyze lachesis/frontends/typescript/fixtures/project example.kuzu
lachesis-query --format text example.kuzu handler-security getDocument
The answer is not a guess and not a severity score. It is the shape of the path.
"status": "UNGUARDED",
"guard_signal": null,
"differential_siblings": [ "getInvoice" ]
getDocument reaches its database call with no guard on the path from the request. guard_signal is null because there is no check to point at. And the record names its guarded twin directly: getInvoice, the sibling that does the same job with the check in place.
Why the sibling is the whole answer
A finding that says “this looks unguarded” is a lead. A finding that says “this is unguarded, and the function three lines up does the identical lookup with an ownership check” is a repro. The guarded sibling is the specification the unguarded one failed to meet. You are not arguing about whether a check belongs there. A near-identical function in the same file already proves it does. That is the difference between a signal a scanner drops and one a reviewer acts on.
On your own code the entrypoint is the same idea without naming a fixture:
lachesis scan ./your-service
It builds and caches the graph, then reports the handlers that reach a sensitive effect on an unguarded path, each one with its guarded peers named. The graph also serves over MCP, so an AI agent reviewing a pull request can ask “does this new handler reach the database without an ownership check, and which sibling shows the check it should have” and get a path back instead of a grep.
Where this stops
Following the value is precise, not omniscient. If the ownership check hides behind dynamic dispatch the analyzer cannot resolve, a call through a table it never sees, a check applied by a framework decorator it does not model, the path can read as unguarded when a guard is really there. A good code graph says so: it marks the edge conservative rather than hiding the doubt, and you read the result as evidence, not a verdict. The point is not that the tool is always right. The point is that it asks the question grep cannot phrase, “did a request value reach this sink without passing a check,” and answers it for every handler in the tree at once, then hands you the sibling that proves the check was supposed to be there.
A missing authorization check is not a line you can search for. It is a path with a guard removed, and you only see it if you follow the value.
Frequently asked questions
What is a missing authorization check?
It is a code path where a request-controlled value reaches a sensitive action, a database lookup, an update, a file read, without any check that the caller is allowed to act on that object. The code runs fine and returns data. It just returns data the caller should not see, which is why it reads as normal in review.
Why can’t grep find a missing authorization check?
Grep and symbol indexes answer where a name appears. A missing check is the absence of code, not a name, so there is no string to match. Two handlers that both call findById look identical to a text search even when one validates ownership and the other does not.
How is this different from IDOR or broken object level authorization?
It is the same bug seen from the code side. IDOR and broken object level authorization describe what an attacker does, change an id and read another object. A missing authorization check is the source-level cause: a path from the request to the object with no ownership guard on it.
Can static analysis find missing authorization checks?
Pattern-based scanners struggle, because there is no fixed pattern for an absent check. A dataflow approach works: build a code property graph, then ask whether a request value reaches a sink without passing a guard, and compare each handler to its siblings that do guard the same sink.
What tool finds missing authorization checks in code?
Any tool that models dataflow rather than text can. Lachesis is an open-source code property graph for C, Python, and TypeScript that reports unguarded handler paths and names the guarded sibling that proves the check belongs there. Install it with pip install lachesis-cpg.
Run this on your own code
Lachesis is the open-source code property graph used in this post. It parses C, Python, and TypeScript with real compilers and answers dataflow questions like the one above, which handler reaches a sink on an unguarded path, across your whole tree at once. Install it with python -m pip install lachesis-cpg and point lachesis scan at a repo, or add it to your agent over MCP so a pull-request reviewer can trace a value instead of grepping for a name. The project is AGPL-3.0 and lives on GitHub. If you would rather have an autonomous researcher prove these bugs on a target you choose, apply to the design partner program.
Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.
