Parameter

Broken object level authorization (BOLA)

Also known as

  • Object-level authorization flaw
  • Horizontal authorization bypass

Broken object level authorization (BOLA) is an API vulnerability where an endpoint accepts an object ID from the client and returns or changes that object without checking the caller owns it, so an attacker swaps in another user's or tenant's ID and reads, edits or deletes their records.

Category
API security
OWASP
API1:2023 Broken Object Level Authorization
Last reviewed

How does BOLA work?

BOLA happens when an API authenticates the caller but never checks that the object named in the request belongs to them. The server knows who you are and trusts you on what you asked for.

APIs are built around object identifiers. A path like /v1/projects/{id}, a body field like "invoice_id", or a GraphQL argument like project(id:) tells the server which record to load. If the handler loads the record by ID alone and returns it, any authenticated user can walk the ID space.

Here is user_a, signed in, fetching their own project:

GET /v1/projects/project_1043 HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token>
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "project_1043", "owner": "user_a", "name": "demo-project", "plan": "free"}

Now the same token, with the ID changed to a project user_a has never been invited to:

GET /v1/projects/project_1044 HTTP/1.1
Host: api.example
Authorization: Bearer <user_a token>
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "project_1044", "owner": "user_b", "name": "other-project", "plan": "team"}

The owner field in the second response proves the flaw: user_a's token got back user_b's object. The correct answer was 404 Not Found (or 403). Everything about the request was valid: a real token, a real ID, a well-formed path. That is why scanners and WAFs miss BOLA. Nothing in the traffic looks malicious; only the authorization decision is wrong.

What does BOLA look like in practice?

The same flaw shows up in every place an API takes an identifier. OWASP's 2023 entry lists IDs in the path, query string, headers and request body, and its example scenarios include an e-commerce API that returns revenue charts for any shop name, a vehicle API that accepts any VIN, and a GraphQL mutation that deletes any document by ID.

  • REST reads. GET /v1/orders/{id} returns another user's order. The most common and easiest to prove.
  • REST writes and deletes. PATCH /v1/projects/{id} or DELETE /v1/files/{id}. Often the read handler has a check and the write handler, added later, does not.
  • Nested resources. GET /v1/projects/project_1043/files/file_77 checks that you can see project_1043 but never checks that file_77 belongs to project_1043. Swap in a file ID from another project and it comes back.
  • GraphQL. A single node(id:) resolver or an updateDocument(id:) mutation serves every type. Authorization in GraphQL has to live in each resolver, and one missing check exposes that type everywhere it is reachable. GraphQL introspection often hands a tester the full list of ID-taking fields.
  • Multi-tenant SaaS. The ID is a tenant or organization, sent as X-Org-Id: org_12 or as a path prefix. Changing it moves the caller into another customer's workspace. This is the highest-impact form, because one bug exposes every tenant.
  • Mobile backends. The app only ever sends the user's own ID, so developers assume nobody will change it. A proxy changes it in seconds.

Random UUIDs slow enumeration but do not fix BOLA. IDs leak through shared links, exports, email notifications, logs, other API responses and browser history. OWASP's IDOR prevention cheat sheet is explicit that access control checks are still required with complex identifiers.

How do you test for BOLA?

The reliable method uses two accounts you control and compares what each can reach. This makes the test authenticated penetration testing by definition: a scan without a session will not find it.

  1. Create two users, user_a and user_b, ideally in two separate tenants or organizations. Give each a few objects of every type: projects, files, invoices, comments.
  2. Map every endpoint that takes an ID. Crawl the app through a proxy, read the OpenAPI spec if one exists, and run introspection against GraphQL. Note where the ID lives: path, query, body, header, cookie, JWT claim.
  3. Record user_b's object IDs from their own traffic.
  4. Replay user_a's requests with user_b's IDs. Keep user_a's token. Change only the ID. Do this for every method the endpoint supports: GET, PUT, PATCH, DELETE, and any custom action like /export or /share.
  5. Compare responses. A 200 with user_b's data, or a 204 on a write, is a finding. Also compare body length and fields: some APIs return 200 with an empty body for denied objects, which is fine, and some return a partial object, which is not.
  6. Test ID formats. Try numeric neighbors, IDs wrapped in arrays ({"id": ["project_1044"]}), the same ID in both path and body with different values, and older API versions (/v1/ versus /v2/) where the check may be missing.
  7. Confirm writes safely. On a write, target an object you own in account B, then verify from B's session that it changed. Never modify data you do not control.

Tools such as Burp Suite's Autorize extension automate step 4 by replaying every request with a second session's cookies and flagging identical responses.

How do you fix BOLA?

Scope every data access to the caller. The query that loads the object should include the user or tenant, so an object the caller cannot see simply does not exist for them.

Express with a SQL query:

// Vulnerable: loads by ID alone.
// const project = await db.query("SELECT * FROM projects WHERE id = $1", [req.params.id]);

app.get("/v1/projects/:id", requireAuth, async (req, res) => {
  const { rows } = await db.query(
    "SELECT id, name, plan FROM projects WHERE id = $1 AND org_id = $2",
    [req.params.id, req.user.orgId]
  );
  if (rows.length === 0) return res.status(404).end();
  res.json(rows[0]);
});

Django REST Framework, scoping the queryset so every detail, update and delete route inherits the check:

from rest_framework import viewsets, permissions

class ProjectViewSet(viewsets.ModelViewSet):
    serializer_class = ProjectSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        # Every lookup, including PATCH and DELETE, goes through this filter.
        return Project.objects.filter(org=self.request.user.org)

What holds across stacks:

  • Put the check in the data layer, not the route. Scoped querysets, repository methods that require a tenant, or database row-level security (see the row-level security video) cover routes nobody remembered to protect.
  • Return 404 for objects the caller cannot see. A 403 confirms the object exists.
  • Take the owner from the session, never from the request. A user_id in the body is attacker input.
  • Write two-user tests that assert user_a gets a 404 on user_b's objects, and run them in CI for every new endpoint.

What does not work: UUIDs alone, hiding IDs in the UI, rate limiting, and WAF rules. None of them change the authorization decision.

BOLA vs IDOR

They describe the same flaw from two eras. IDOR, from the OWASP Top 10 of 2007, names the symptom in web apps: a direct reference to an object that the user can change. BOLA, from the OWASP API Security Top 10, names the root cause for APIs: object-level authorization is missing or broken. CWE-639 lists both as alternate terms for the same weakness.

IDORBOLA
OriginOWASP Top 10 2007OWASP API Security Top 10 2019 and 2023
Current homeA01 Broken Access ControlAPI1:2023
Typical contextWeb pages, forms, file downloadsREST and GraphQL APIs, mobile backends
FramingThe exposed referenceThe missing authorization check

In practice, use BOLA when writing up an API finding and IDOR for a web app, and link both. See the IDOR page for the web-app framing. If the attacker reaches a function they should not have (an admin endpoint), not another user's object, that is broken function level authorization.

Written by Parameter · Last reviewed