// guides

BOLA IDOR Testing: The Multi-Account Object-Swap Harness

BOLA IDOR testing concept: one request replayed under several identities reaching objects owned by other tenants

BOLA IDOR testing checks one thing: when an API accepts an object ID from the client, does it verify that the authenticated caller may act on that specific object? Broken Object Level Authorization still ranks API1 in the OWASP API Security Top 10 because most teams authorize the route, not the object, and no scanner can tell whose invoice is whose.

The short answer

  • BOLA (the API name for IDOR) happens when an endpoint takes a client-supplied object ID and never checks that the caller may act on that object. It is OWASP API1 in 2019 and 2023.
  • Test with four tokens (owner, same-tenant peer, second tenant, low or no privilege), swap IDs in every location and method, and diff the response body, not just the status code.
  • GraphQL base64 global IDs, batch arrays and nested child routes are where systematic testing finds what ad hoc testing misses.
  • UUIDs, WAFs and gateways do not fix it. Subject-scoped data access, row-level security and per-endpoint negative tests in CI do.

Key takeaways

  • BOLA and IDOR are the same object-level flaw. OWASP ranks BOLA as API1 in both the 2019 and 2023 API Security Top 10.
  • Test with four token contexts: owner, same-tenant peer, second tenant, and lower or no privilege. The intra-tenant peer case is the one most often skipped.
  • Swap IDs in every location and method, including GraphQL node() lookups, batch arrays, nested child routes, read-shaped POSTs and agent tool arguments, and diff the body, not just the status code.
  • UUIDs, WAFs, gateways and rate limits do not stop BOLA. Ownership lives in your database, so the check has to as well.
  • Fix it once: a deny-by-default enforcement point, subject-scoped repositories, Row-Level Security for tenancy, and a negative CI test for every object-touching route.

BOLA vs IDOR vs BFLA vs BOPLA: what is the difference?

Between BOLA and IDOR, none worth arguing about. IDOR (Insecure Direct Object Reference) is the older web term. BOLA (Broken Object Level Authorization) is the name OWASP uses in its API Security Top 10. Both describe an endpoint that takes a client-supplied identifier and never checks whether the authenticated subject may perform the requested action on that object.

The names differ in the fix they suggest. “Insecure direct reference” points toward making the reference indirect or unguessable. “Broken authorization” points at the real defect: the missing subject-to-object decision. A UUID in the path does not change whether that decision happens.

The 2023 edition of the API list splits access control into three classes by granularity. BOLA holds API1, as it did in 2019. BOPLA is new in 2023: it merges the 2019 entries for Excessive Data Exposure and Mass Assignment.

Class (2023) Question the API fails Typical bug How the test differs
BOLA (API1) May this subject act on this object? GET /invoices/4411 returns another customer’s invoice Same endpoint, same role, swapped object ID
BOPLA (API3) May this subject read or set this field? PATCH with "role":"admin" is accepted, or a response leaks password_hash Your own object, extra or hidden properties
BFLA (API5) May this role call this function at all? A regular user calls POST /admin/users Lower-privilege token against privileged routes

Mixing these up costs you twice. A mass-assignment bug filed as BOLA gets the wrong fix. And a role check (“is this user an editor?”) gets mistaken for an object check (“may this editor edit document 4411?”). Most BOLA we find in code sits behind a role check that someone believed was enough.

Why is BOLA still the number-one OWASP API risk?

Because authorization gets applied per route or per controller, while every object read and write needs its own decision. Developers rarely skip the check on purpose. The usual architecture makes the unsafe path the default one.

  • Find-by-id ORMs. Order.find(params[:id]) returns any row in the table. Scoping to the caller is opt-in, and every new endpoint is a new chance to skip it.
  • Route-level middleware. It verifies the token and the role, then hands the controller an ID nobody checks.
  • Microservice trust gaps. The gateway authenticates the request. The downstream service trusts the gateway plus a caller-supplied X-Tenant-Id header.
  • Shadow versions. /v1 left the docs but still runs in production, without the ownership check /v2 received (API9:2023, Improper Inventory Management).
  • Tooling blindness. SAST and generic DAST do not know the ownership model. Neither knows that invoice 4411 belongs to customer A.

Finding BOLA takes two things a pipeline rarely has: knowledge of the data model and at least two valid identities. That is why the class shows up so heavily in bug bounty programs. Hunters with two accounts find it. Scanners holding one service token do not.

How do you build a multi-account harness for BOLA IDOR testing?

Create four token contexts before testing starts, and make sure each one owns real objects:

  1. Account A, the victim, in tenant 1.
  2. Account B, a peer with an identical role in tenant 1.
  3. Account C in tenant 2.
  4. A lower-privileged or unauthenticated context.

Together they cover three axes: horizontal (same role, same tenant, different user), cross-tenant (a different organization) and vertical (a lower-privileged principal reaching a higher-privileged object or action).

Most programs test only cross-tenant. The intra-tenant peer case affects more users and gets missed more often, because many implementations check “same org” and stop there.

Map the object model before you swap anything: OpenAPI specs, Postman collections, JS bundles and source maps, intercepted mobile traffic, GraphQL introspection. kiterunner and ffuf find routes, Arjun finds hidden parameters, and archived endpoints reveal retired API versions. Our API penetration testing checklist for 2026 covers that recon phase in detail.

Then run the core loop on every object-touching request:

  1. Get a baseline 2xx with the owning account.
  2. Replay the identical request with a non-owning token and the owner’s object ID.
  3. Diff the full response: status, body content and length, headers, timing.
  4. Re-fetch as the owner to see whether state changed.

In Burp Suite, Autorize replays every proxied request with a second session and flags lookalike responses. AuthMatrix gives you a repeatable user-by-request matrix, and Auth Analyzer runs several sessions in parallel with per-session CSRF token extraction. In Caido, match-and-replace rules handle the swap.

Whatever the tool, automate token refresh. An expired token returns 401, which looks exactly like a pass.

Diff the body, and decide on 403 or 404

Status codes mislead in both directions. Returning 404 for objects the caller cannot see removes the existence oracle; 403 confirms the object exists. Either is defensible if it is consistent, and many teams standardize on 404 for reads. On GraphQL, return null with the same error for both cases.

Inconsistency is the actual finding. If /invoices/{id} returns 404 but /invoices/{id}/pdf returns 403, an attacker can map which IDs exist. Error text and response timing must not separate “missing” from “forbidden” either. Before you mark a request as passing, rule out three false negatives:

  • Cached responses that serve stale data.
  • Soft-deleted or archived objects that skip the ownership filter.
  • A 200 with an empty body that hides a successful write.
BOLA IDOR testing harness with four token contexts replaying the same object request
Four identities, one object: the minimum harness for object-level authorization tests

Which ID locations, methods and surfaces does the swap loop have to cover?

Every place the server reads an identifier from, with every method. Swap each location on its own: path segment and query string; JSON body, form field and multipart part; headers such as X-User-Id or X-Tenant-Id, and cookies; GraphQL variables and inline arguments; batch array elements.

A common real bug authorizes the path ID but acts on the body ID. Send PATCH /docs/{yours} with {"id": "{victims}"} in the body and check which record changed.

GET is the least interesting method. The damaging findings sit in PUT, PATCH and DELETE, and in read-shaped POSTs: CSV and XLSX export, invoice and PDF rendering, report generation, share-link creation, thumbnail previews and search. A request that returns 403 but still mutates the row, fires a webhook or queues an email is a finding.

Harvest IDs for the whole engagement, not only during recon. They leak through exports, notification emails, PDFs, JS bundles, logs, referrer headers, support tooling and third-party integrations.

Nested routes

Routes such as /orgs/{orgId}/projects/{projectId}/files/{fileId} often validate membership in orgId and then load fileId globally. Keep your own parent IDs and swap only the child to another tenant’s file. Then swap the child to a file in your own org, inside a project you cannot access.

GraphQL global IDs

Decode them; they are not random. Under the Relay Global Object Identification convention, IDs passed to node(id: ID!) are usually base64 of TypeName:primaryKey. Decode, change the key, re-encode, and they become enumerable. The type prefix also maps the object model: Invoice, ApiKey, AuditLog.

Test every path to an object: node and nodes, typed queries such as invoice(id:), and traversal from your own viewer into organization.members.invoices. Resolvers often authorize the root field and trust everything below it.

Failures usually return HTTP 200 with an errors array, so compare the data payload. Aliases pack hundreds of node lookups into one request, which defeats per-request rate limits. graphw00f fingerprints the engine, Clairvoyance rebuilds schemas when introspection is off, and InQL generates queries inside Burp. This is the core of our GraphQL security testing.

Batch and bulk endpoints

Batch endpoints often authorize the request once instead of each object. The server validates only the first element, or checks the envelope rather than each item, and rate limits count requests rather than items. Send mixed payloads such as [own, victim, own], then move the victim to the first and last positions.

Look for partial success: a 207, or a 200 with per-item results where the victim’s item went through. Check whether a failed item rolls back the batch or leaves earlier writes committed. A bulk delete that accepts 500 IDs turns one forged request into 500 BOLA calls without tripping a rate limit.

Agent tools and MCP servers

An MCP tool such as get_document(id) receives its object ID from model context, not from a UI. An indirect prompt injection in an email, ticket or retrieved chunk can name another tenant’s ID. If the MCP server trusts the agent’s service account, the agent becomes a confused deputy and fetches the object.

The check must run against the end user the agent acts for, with the agent’s arguments treated as untrusted input. To test it, plant a document telling the agent to fetch a second tenant’s object, run the agent as a first-tenant user, and read the tool-call trace. We run this in MCP server and tool-chain security testing.

Do UUIDs, a WAF, or an API gateway stop BOLA?

No. Unguessable IDs make random enumeration harder, and edge controls block malformed traffic. A BOLA request is neither random nor malformed: it is authenticated, well-formed and indistinguishable from legitimate traffic.

ID scheme Enumerable? Stops BOLA?
Sequential integer Trivially No
UUIDv1 / ULID Partly, because they are time-ordered No
GraphQL Relay global ID Yes, after base64 decoding No
UUIDv4 (122 random bits) Not by guessing No, once the ID leaks

Use UUIDv4 or random 128-bit IDs as defense in depth. OWASP’s API1:2023 entry lists random identifiers alongside per-object authorization checks, not instead of them. A WAF or gateway cannot evaluate ownership, because ownership lives in your database. Rate limiting does nothing against one crafted request carrying an ID harvested from a shared PDF.

Gateway-only enforcement also leaves service-to-service calls, background jobs and queue consumers unprotected. Any internal caller that reaches the service can address any object.

How do you fix BOLA once and keep it fixed?

Move the decision out of individual handlers. A Policy Enforcement Point (PEP) in shared middleware or the data-access layer asks a Policy Decision Point (PDP) one question: may this subject perform this action on this resource in this context? It defaults to deny. NIST SP 800-162 describes this attribute-based (ABAC) model.

The subject comes from the verified token, typically the JWT sub claim. It never comes from a client-supplied user_id, a tenant header, or a subdomain that is not cross-checked against the token.

Pattern Coverage Drift risk
If-statement per endpoint Only where someone remembered High
Shared middleware PEP Every routed request Medium: misses jobs and internal calls
Subject-scoped data access Every query through the repository Low
PostgreSQL Row-Level Security Every query on the connection Lowest for tenancy

Our recommendation: make the unscoped query hard to write. Use current_user.orders.find(id) instead of Order.find(id), design repository methods that cannot be called without a subject or tenant argument, and back tenancy with Row-Level Security.

Framework-native tools cover most applications: Spring Security AuthorizationManager, ASP.NET Core resource-based IAuthorizationHandler, Pundit or CanCanCan in Rails, Django REST Framework get_queryset with django-guardian, and NestJS guards with CASL.

When sharing, folders and delegation outgrow framework code, bring in a policy engine: Open Policy Agent for general policy, AWS Cedar for typed and analyzable policies, OpenFGA or SpiceDB for Zanzibar-style relationship graphs, Cerbos or Casbin for lighter embedded decisions.

Regression tests in CI

Every endpoint that touches an object gets two tests: a positive one with the owner’s token, and a negative one where persona A requests persona B’s object. The negative test asserts your chosen 403 or 404 and no leak through body, headers, error text or timing. For writes, it re-reads the object as the owner and asserts nothing changed.

Generate the skeletons from your OpenAPI spec and fail the build when a route ships without a negative test. Coverage drifts per route, version and service; a green check on one endpoint proves nothing about its siblings.

In production, log every authorization decision with subject, object type, object ID, action, result and request ID. Alert on a per-session spike in denials and on one subject touching an unusual number of distinct object IDs. That second pattern is the enumeration signature.

How do you write up a BOLA finding so it gets triaged as critical?

Show impact, not mechanism. Severity is usually High to Critical because the attacker is already authenticated and the request looks legitimate. A write-up that gets triaged that way includes:

  • Both identities and their roles.
  • The baseline and replayed request pair.
  • The response diff.
  • The class of data exposed.
  • An estimate of how many objects are reachable.
  • Whether writes or deletes also work.

For a cross-tenant read and write, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N scores 8.1. Scope-changed or full tenant takeover variants score higher. Score what you actually reached; do not copy a template vector.

Map the finding to CWE-639 (Authorization Bypass Through User-Controlled Key), then add the references a triager expects:

  • CWE-862 (Missing Authorization) when there is no check at all.
  • CWE-863 (Incorrect Authorization) when a check exists but tests the wrong thing.
  • OWASP API1:2023, or A01:2021 Broken Access Control for web application findings.
  • OWASP ASVS v4.0.3 chapter V4 (Access Control) for the failed requirement.
  • The IDOR test in the OWASP WSTG authorization chapter.

Close with root cause (an unscoped lookup in the shared repository, for example) and the central fix. A ticket that only says “add a check to this endpoint” produces the same finding next quarter on a different route.

GraphQL IDOR concept: an opaque global ID decoded to reach nodes in another tenant's graph
Opaque GraphQL IDs decode to a type and a key, so they enumerate like integers
Batch endpoint BOLA: a checkpoint validates only the first item while a foreign object passes through
Bulk endpoints often authorize the envelope or the first item, not every object

FAQ

Do UUIDs prevent IDOR or BOLA?

No. Random UUIDv4 identifiers stop guessing, but IDs leak through exports, emails, PDFs, logs and JS bundles. Once an attacker holds a valid ID, only a per-object authorization check stops the request.

Should an API return 403 or 404 for an object the caller does not own?

Either works if it is consistent across every endpoint. Many teams use 404 for reads to hide whether the object exists. Mixed 403 and 404 responses on related routes are themselves a finding, because they let an attacker map valid IDs.

Can an API gateway or WAF fix BOLA?

No. A BOLA request is authenticated and well-formed, and the gateway has no view of who owns which row. Gateway-only enforcement also leaves internal service calls, background jobs and queue consumers unchecked.

Which Burp extensions help with IDOR testing at scale?

Autorize replays proxied traffic with a second session, AuthMatrix builds a repeatable user-by-request matrix, and Auth Analyzer runs several sessions in parallel. For GraphQL, InQL generates queries inside Burp. Automate token refresh, or expired tokens will look like passes.

Does BOLA apply to AI agents and MCP servers?

Yes. An MCP tool receives object IDs from model context, so an indirect prompt injection can name another tenant's object. The server must authorize against the end user the agent acts for, not the agent's service account.

If you want the four-persona harness run against your REST, GraphQL and agent tool surfaces, with the central fix written into the report, see our broken access control and IDOR testing.



// get started

Work with AgentOffense

Tell us about your target and goals. We’ll reply with scope and a fixed-price quote — usually within one business day.

./request_engagement