Broken Object Level Authorization (BOLA / IDOR)
ID |
broken_object_level_authorization |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Family |
API1:2023 - Broken Object Level Authorization |
CWE |
CWE-639, CWE-284 |
Resource |
authorization |
Language |
any (model-level) + Java / C# (AST Phase C) + Python / JS / Go / PHP (regex Phase B) |
Description
Reports API endpoints that access objects by an ID-shaped path or query parameter (/orders/{id}, /users/:id, /documents?id=…) and require authentication, but show no model-level evidence that the handler verifies the caller’s ownership of the referenced object.
A tool registered on an MCP server the repository ships is judged the same way. A client invokes a tool with a POST whose arguments travel in the request body, so for an MCP tool an ID-shaped argument (delete_document(documentId)) counts as the object’s ID. An MCP resource is read by its URI, and the variables of its URI template (users://{userId}/profile) are its path parameters.
This is the classic Insecure Direct Object Reference (IDOR) pattern and the OWASP API1:2023 — Broken Object Level Authorization — entry. Per-language source-walking (Phase B) can lift the verdict to HIGH when no explicit ownership check is found in the handler body; AST-based confirmation (Phase C) extends the check across one-hop helper calls and ORM-side row scoping in Java and C#.
Rationale
Authentication confirms who the caller is. Object-level authorization is the orthogonal question — which objects is this caller allowed to see or change?
An endpoint that returns the order whose id is in the URL — without verifying the order belongs to the caller — lets any authenticated user enumerate every order in the system simply by incrementing the id. BOLA is the most-reported finding in public API breach catalogs (OWASP API1 since 2019 and unchanged at the top in 2023). Real-world consequences include cross-tenant data leaks in B2B SaaS, mass enumeration of medical records, and credential / token theft via account-recovery flows that fetch by user id.
Authorization is not "did I write a check?" but "does the check actually scope by user identity?" — a frequent failure mode is a guard that confirms the caller is logged in (already enforced by auth) without comparing the resource owner to the caller, leaving BOLA wide open.
Remediation
The fix is to bind every object-by-id access to the caller’s identity at the data layer or via an explicit guard. In order of robustness:
-
Repository-scoped queries — the strongest. Replace
findById(id)withfindByIdAndOwner(id, currentUser)(Spring Data),Model.objects.filter(user=request.user).get(pk=id)(Django),findOne({_id: id, userId: req.user.id})(Mongoose),Where("user_id = ?", userID).First(&order, id)(GORM), orOrder::where('user_id', auth()→id())→findOrFail($id)(Eloquent). The caller cannot escape the filter. -
Policy / authorization framework —
@PreAuthorize("@bookService.canRead(#id, principal)"), ASP.NETIAuthorizationService.AuthorizeAsync(user, resource, policy), Laravel$this→authorize('view', $order), DRFcheck_object_permissions(request, obj). -
Explicit guard — load the object, compare
obj.userId == currentUser.id, return 403 / 404 on mismatch. Acceptable but easy to skip on a refactor; prefer the patterns above.
Avoid relying on "the user only sees their own ids in the UI" — assume any authenticated caller will iterate the id space.
Configuration
The detector recognises a configurable set of id-shaped parameter patterns (id, *Id, *_id, uuid) — patterns can be extended via the idParameterPatterns property. The model-level pass is the cheap floor; per-language Phase B / Phase C source walking is automatic when the handler file is parseable.
Two further properties tune what counts as an ownership check:
-
mediatorMethods— per-language names of your own authorization helpers (assertCanAccess,check_object_permissions). A call to one inside the handler counts as an ownership check. Do not list role checks or authentication-only helpers here: they answer a different question and would silence the finding on every guarded endpoint. -
privilegedRoles— role names whose holders are meant to reach objects that are not their own, so that an endpoint gated on one of them is not reported. The shipped list covers the administrator / root family in English (admin,administrator,superadmin,superuser,root,sysadmin) and replaces, rather than extends, the default when you set it — an application whose admin role isgestororbeheerderlists it here to avoid a false positive on every admin endpoint. Names are compared whole and case-insensitively with Spring’sROLE_/SCOPE_prefix stripped, soTENANT_ADMINis not treated as privileged (a tenant admin reaching another tenant’s object by id is the canonical multi-tenant BOLA), and a list of alternatives counts only when every name in it is privileged (hasAnyRole('ADMIN','USER')lets an ordinary user in). Scope-ambiguous roles —manager,support,staff,moderator,operator— are deliberately not shipped as privileged, because "a support agent can read any customer record by id" is a finding most teams want to see; add them if your application intends otherwise. -
callerIdentities— how your source names the authenticated caller. The ownership question is "is this the caller’s object?", so the reader has to recognise the caller before it can ask: a query rooted at it (current_user.posts.find(params[:id])), an owner compared against it (@post.user == current_user) and a scoping column bound to it (where(user_id: current_user.id)) are one test applied to whatever your principal is called. The shipped list covers the names a framework convention makes a principal (current_user,current_account,current_person,current_member,current_customer,current_admin,User.current,Person.current,Account.current) and, likeprivilegedRoles, replaces rather than extends the default when you set it; an optional leading@is always accepted, so@current_userneeds no entry of its own. The list is deliberately short, because source text cannot distinguish a principal from a tenant and both are conventionally namedcurrent_<something>:current_provideroractive_session.user_idare callers and are exactly what this property is for, while@current_communityorcurrent_tenantare tenants — a query rooted at a tenant reaches every row belonging to it, including every other user’s, so listing one here does not prove ownership but silences every cross-tenant finding (one user reaching another’s object inside the same tenant) at high confidence and without a trace in the report.
A framework’s own object-level check answers for one object. When the endpoint’s declaration names the object it is checked against — Symfony’s #[IsGranted('view', 'project')] hands the voter a subject — the finding is suppressed only for the id parameter that names that subject. The correspondence is made on the name (project matches {project}, {projectId} and {project_id}) or, for a bare {id}, on the collection segment it hangs off (project matches /api/projects/{id}). On /projects/{projectId}/tasks/{taskId} the voter was asked about the project and nothing at all was asked about the task, so the task keeps its finding and the project travels with it under suppressed_id_parameters. This matters most for a class-level grant, which the framework propagates to every action of the controller: read as an answer for the whole endpoint, one annotation deleted the object finding on every id of every action beneath it. A subject that corresponds to no id parameter suppresses nothing.
What a finding carries to tell the BOLA variants apart. The HTTP method separates a read from a write. Three further properties describe the endpoint itself, so a consumer of the report (a pentest tool, a triage queue) can pick the variant without guessing from parameter names:
-
id_parameter_roles— what each id-shaped path parameter selects, read off the path’s structure rather than its name.objectis the last id, the one that picks the row ({projectId}in/orgs/{orgId}/projects/{projectId});scopeis an id another id follows, partitioning the collection the row is picked from ({orgId}there), which is where a tenant-level BOLA lives whether the application calls its tenantorgId,cidorsiteId;parentis an id that other segments follow but no other id (/users/{id}/avatar,/orgs/{orgId}/projects, the{id}of/basket/{id}/coupon/{coupon}), where the path cannot say whether it is a partition or one row’s sub-resource. -
id_uses— the handler lines that use the unchecked id, asfile:line: code, at most five: the lookup or the mutation that was not scoped to the caller. The signature that only binds the parameter is left out. Empty when the handler could not be read. -
response_sensitive_fields— the sensitive fields of the type the endpoint returns (name, the wire name when the type declares one, and itssensitivity_tag). A non-empty list marks a property-level read: the object a caller can reach by id also returns data such as an email or a password hash. Schemas declared in an API descriptor count, since the question is what the response carries.
An empty phase_b_evidence is the strongest form of the finding, not a gap: the handler was walked and no ownership check was seen, which is why such a finding carries confidence: high. The list is filled when a single unconfirmed signal was seen (confidence: medium), and phase_b_checked: false means the handler could not be walked at all.
Endpoints nobody can call, and ids nobody reads. An endpoint closed to every caller (Spring Security’s .denyAll(), JAX-RS / Micronaut @DenyAll, or an Express route whose middleware rejects every request, such as app.delete('/api/Products/:id', security.denyAll())) exposes no object to anyone, and is withdrawn as endpoint_denies_all. A handler that binds the id and never reads it looks nothing up by it, so changing the id reaches nothing: that is an unused input, not an authorization flaw, and is withdrawn as id_not_used. This applies only to Java, Kotlin and C#, where the id is a named method parameter. Python class-based views read it through self.kwargs or get_object() without naming it, and JavaScript reads it off req.params.
Express handlers in other modules. For app.get('/rest/basket/:id', utils.asyncHandler(retrieveBasket())), the detector follows retrieveBasket into the module that implements it, through the wrapper, and places the finding there. When the last function on the route only passes the request on with next() (the route is served by a handler registered elsewhere, such as a generic REST router), the body is reported as not inspected (phase_b_checked: false, confidence: medium) rather than as clean.
Suppressions are reported. This detector decides not to report far more often than it reports: the framework already states the object-level check, a central interceptor validates the object id, the application routes data per authenticated tenant, or the handler walk finds an ownership check. Each of those removes an endpoint that matched the candidate shape, and a wrong suppression is the expensive direction — it deletes a real finding.
So every suppression is emitted as a finding of this detector at info severity, titled BOLA candidate suppressed: <METHOD> <path>, carrying suppressed_by (which decision removed the candidate: object_level_requirement, central_tenant_isolation, central_object_id_guard, handler_ownership_check, endpoint_denies_all, id_not_used) and suppression_evidence (what was matched and how it resolved). info keeps them out of the default view and out of the project’s risk level, so auditing the decision cannot bury the confirmed findings. Set reportSuppressions: false in a large repository where the informational volume is unhelpful.
Unless your project uses unusual id-parameter naming, non-English role names, its own authorization helpers, or its own name for the authenticated caller, you typically do not need to configure this detector. What the application profile contributed, on every finding. When a scan runs with an application profile, the profile can only add to the four vocabularies above — the names your project invented for its principal, its privileged roles, its authorization helpers and its ownership columns. It can never remove a shipped name, disable this detector or lower a severity. Because a profile is produced by reading your repository and can be wrong, every finding and every suppression says what part the profile played:
-
profile_facts— the fact behind the verdict and thefile:linewhere it is asserted in your code, so you can open that line and disagree with it. All four families report it: the authorization helper that was called, the privileged role that was required, the principal that was recognised and the ownership column that was matched. The one gap is an identity comparison in Ruby, where the caller vocabulary is matched by a pre-compiled expression that cannot say which of its names matched; such a verdict reports no fact. Absence therefore means “no fact was involved” everywhere except that case. -
profile_source— which model produced the facts, which provider served it and when. -
profile_not_evaluated— the questions the profile did not answer, one line each. A scan with no profile says so in a single line. This is what distinguishes "the vocabulary was widened and the verdict did not change" from "the question was never asked".
A suppression a profile fact caused is still emitted at info with the fact named, never dropped: a fact that is wrong about one of your methods withdraws a real finding, and that is the one direction the report has to be able to show you.
References
-
OWASP API Security Top 10 (2023) - API1:2023 - Broken Object Level Authorization.
-
CWE-639 : Authorization Bypass Through User-Controlled Key.
-
CWE-284 : Improper Access Control.
-
OWASP Cheat Sheets Series: Authorization Cheat Sheet.
-
OWASP Cheat Sheets Series: Insecure Direct Object Reference Prevention Cheat Sheet.