Semgrep + LLM for IDOR Detection: Fewer False Positives by Scoping the Review
IDOR (Insecure Direct Object Reference) findings from broad security scans are often noisy. A tool flags every place a user-supplied ID touches a database, and then you spend hours confirming that half of them are already protected by authorization checks elsewhere in the call chain. The fix isn’t to stop looking for IDORs; it’s to narrow the review so an LLM only analyses candidates that Semgrep has already identified as potentially vulnerable. You then use the LLM to answer one question: Is there a real authorization gap here, or is the parameter properly validated for the logged-in user’s role?
This post describes a two-step method I’ve used in practice: (1) Use Semgrep to find all known database calls within an API route’s lifecycle that may look vulnerable (e.g. lookups keyed by request params or arguments). (2) Feed those call sites to an LLM (e.g. Claude Code) and ask it to analyse whether there are additional authorization checks or other mechanisms that verify the parameters are properly authorized for the logged-in user. This combined approach significantly reduces false positives by only flagging truly unprotected data access paths.
Why Semgrep + LLM
- General scan: You ask an LLM to "find IDORs in this codebase." It may flag many routes that use
params[:id]orreq.params.idin a query, but it often can’t reliably trace the full request lifecycle (middleware, service layers, shared helpers), so you get both missed issues and many false positives. You end up manually re-checking every finding. - Semgrep-first: You run Semgrep with rules that match only database (or ORM) calls that are keyed by user-supplied input (request params, query args, body fields) within API entry points (routes, controllers, resolvers). You get a curated list of call sites. You then ask the LLM: "For each of these snippets, and the surrounding route/handler code I’m providing, determine whether there is an authorization check that ensures (a) the resource belongs to the current user, or (b) the user’s role is allowed to access it. If not, flag it as a potential IDOR."
So: Semgrep finds the suspicious data-access patterns; the LLM decides whether they’re actually protected. The model’s job is scoped and binary: "Is this parameter properly authorized, or not?" That keeps false positives down and makes the output actionable.
Step 1: Use Semgrep to Find Potentially Vulnerable DB Calls
The goal is to identify every place in an API route’s lifecycle where a user-supplied identifier is used in a database (or ORM) call. You want candidates, not final verdicts. Below are example patterns for a few common stacks; you can extend them to your framework and ORM.
Node/Express (or similar): Params in DB/ORM Calls
Look for route handlers or services that use req.params, req.query, or req.body inside a call to a DB client or ORM (e.g. findByPk, findOne, findById, query).
rules:
- id: express-db-call-with-params
pattern-either:
- pattern: |
$ORM.findByPk($REQ.params.$FIELD, ...)
- pattern: |
$ORM.findOne({ where: { ... $REQ.params ... } })
- pattern: |
$ORM.findById($REQ.params.$FIELD)
message: "DB/ORM call keyed by request params - verify authorization with LLM"
languages: [javascript]
severity: WARNING
- id: express-db-call-with-query
pattern: |
$ORM.$METHOD({ ... $REQ.query ... })
message: "DB/ORM call keyed by request query - verify authorization with LLM"
languages: [javascript]
severity: WARNING
You can add more pattern-either entries for your actual ORM (e.g. Prisma findUnique, Knex where, etc.), always binding the lookup to req.params, req.query, or req.body.
Django: Request Data in Queries
Find views or view helpers that use request.GET, request.POST, or URL kwargs in get(), filter(), or similar.
rules:
- id: django-get-with-request-data
pattern-either:
- pattern: $MODEL.objects.get(...$REQUEST.GET...)
- pattern: $MODEL.objects.get(...$REQUEST.POST...)
- pattern: $MODEL.objects.filter(...$REQUEST.GET...)
- pattern: $MODEL.objects.get(pk=$KWARGS.$ID)
- pattern: $MODEL.objects.get(id=$KWARGS.$ID)
message: "Django ORM get/filter with request or URL param - verify authorization with LLM"
languages: [python]
severity: WARNING
Adjust $KWARGS if your URL routing passes the id via a different name (e.g. pk, user_id, order_id).
Rails: Params in Model Lookups
Find controller actions (or code called from them) that use params in find, find_by, or where on a model.
rules:
- id: rails-find-with-params
pattern-either:
- pattern: $MODEL.find($PARAMS[...])
- pattern: $MODEL.find_by(...$PARAMS...)
- pattern: $MODEL.where(...$PARAMS...)
message: "Rails model lookup with params - verify authorization with LLM"
languages: [ruby]
severity: WARNING
GraphQL Resolvers: Args in DB Calls
Find resolvers that pass args.id (or similar) into a DB/ORM call without an obvious ownership or role check in the same snippet.
rules:
- id: graphql-resolver-db-with-args
pattern-either:
- pattern: |
$DB.$METHOD(...$ARGS.$FIELD...)
- pattern: |
$ORM.findUnique({ where: { id: $ARGS.id } })
message: "GraphQL resolver DB call with args - verify authorization with LLM"
languages: [javascript]
severity: WARNING
Run Semgrep, then export or copy the list of matches (file, line, and code snippet). That list is your input for Step 2.
Step 2: Use an LLM to Analyse Authorization
For each Semgrep match, you need the LLM to see enough context to decide whether the parameter is properly authorized. That usually means:
- The route/handler (and middleware or decorators, if relevant).
- The full function containing the DB call, and any callees that perform the actual query or that perform authorization.
Then ask the LLM to answer a fixed set of questions so the output is consistent and easy to triage.
What to Send the LLM
For every Semgrep finding, provide:
- The matched snippet (the DB/ORM call that uses user input).
- The enclosing route/handler/controller/resolver (full function body).
- Any shared helpers or middleware that run in the request lifecycle (e.g. "ensure user can access this resource").
- The framework and role model (e.g. "Django, user is on
request.user; resource ownership is viaresource.user_id == request.user.id").
You can batch several findings in one prompt (e.g. 5–10 per message) to reduce round-trips, as long as each has the above context.
What to Ask the LLM
Use a single, repeatable prompt so the model behaves consistently. For example:
- "For each of the following code snippets, the snippet is a database (or ORM) call that uses a user-supplied parameter (e.g. id, user_id, order_id from the request). I’ve included the full route/handler and any relevant middleware or helpers. For each snippet, determine:
- Is there an explicit authorization check that ensures the resource being accessed belongs to the logged-in user (e.g. by comparing resource owner to
request.useror equivalent)? - If the endpoint is not user-scoped (e.g. admin-only), is there a check that the current user has the required role or permission?
- Is the user-supplied parameter used only after such a check (i.e. no use-before-check or TOCTOU)?
If any of these are missing, list the snippet as a potential IDOR and briefly state what’s missing. If all are satisfied, state that the finding is a false positive and why (e.g. 'ownership checked in middleware X')."
- Is there an explicit authorization check that ensures the resource being accessed belongs to the logged-in user (e.g. by comparing resource owner to
You can add more specific checks (e.g. "Is the param validated to be in the user’s allowed tenant/list?") to match your app’s authorization model.
Why This Cuts False Positives
- Semgrep does not try to reason about control flow or call graphs; it only finds candidates where user input reaches a DB call. That keeps the rule set simple and avoids complex data-flow analysis.
- The LLM only has to answer: "Given this route and this call site, is there a proper authorization check?" It doesn’t have to discover every DB call in the repo; you’ve already narrowed the set. So you get:
- Fewer false positives: Many Semgrep hits will be explained by the LLM as "ownership checked in helper X" or "admin-only route, role checked in middleware."
- Clear true positives: When the LLM says "no ownership or role check," you have a concrete location and a short justification to fix.
Some fine-tuned LLM agents can definitely work better at this task, but that’s out of scope for now.
Suggested Workflow
- Define Semgrep rules that match DB/ORM calls keyed by request params, query, body, or resolver args in your stack (Express, Django, Rails, GraphQL, etc.). Tune them so they don’t miss your main patterns but also don’t match unrelated code (e.g. internal IDs only set by the server).
- Run Semgrep on the repo and collect findings with file, line, and snippet. Optionally include a few lines of context above/below in the export.
- Gather context for each finding: the full route/handler and any middleware or shared auth helpers. You can script this (e.g. read file from path, extract function by line number).
- Batch findings (e.g. 5–10 per prompt) and run the LLM with the same authorization checklist every time. Paste in snippets + context and the prompt from above.
- Triage the LLM output: treat "potential IDOR" as a ticket to fix; treat "false positive" as closed once you’ve spot-checked a sample.
- Re-run after code changes: Semgrep again, then re-run the LLM on new or changed matches. You can automate Step 1 and 2 in CI and keep the LLM step for periodic or PR-based review.
Summary
- IDOR detection benefits from a two-step pipeline: Semgrep finds likely vulnerable DB calls in API routes; an LLM analyses those calls for authorization checks.
- Step 1: Use Semgrep rules that match database/ORM calls keyed by user-supplied input (params, query, body, GraphQL args) inside route handlers, controllers, or resolvers. Export the list of matches with snippets.
- Step 2: For each match, give the LLM the matched snippet plus the full route/handler and relevant middleware or helpers. Ask it to determine whether the parameters are properly authorized for the logged-in user’s role (ownership or role checks, no use-before-check).
- This combined method keeps the LLM’s task small and binary ("authorized or not?"), which significantly reduces false positives and surfaces only truly unprotected data access paths for remediation.
Once this is in place, you get consistent, repeatable IDOR coverage without manually hunting every params[:id] and without the noise of a single, unfocused scan.
Please subscribe if you would like to receive more content like this