Skip to content
RESEARCH INDEX BREACHROAD / INTELLIGENCE NOTE

IDOR and BOLA: testing object-level authorization

Why object-level authorization fails most often: overlooked IDOR variants, UUID myths, durable fix patterns, a testing method and log-based detection.

PUBLIC RESEARCH
AUTHOR
/ CEO of Breachroad · OSCP · PNPT
PUBLISHED
25 July 2026
READING TIME
17 min read
TOPIC
Penetration Testing and AppSec
IDOR and BOLA: testing object-level authorization

The most common vulnerability I find in APIs is not sophisticated. The application correctly checks who sent the request, then returns the object that was asked for without asking whether this particular user is entitled to it. That is IDOR — insecure direct object reference — or, in OWASP API Security terminology, BOLA: broken object level authorization. It has topped the API risk list for years and shows no sign of giving up the position.

The reason this class persists is structural. Authentication can be centralised in one middleware. Function-level authorization (“may the admin role call this endpoint?”) can be too, because it depends only on role and path. Object-level authorization, by contrast, requires knowledge of the data ownership model and must be decided separately for every object on every path. It is therefore scattered across the entire application and breaks exactly where nobody looked.

Three different questions

It helps to separate three questions that code tends to blur together.

First: who are you? That is authentication. A session or token handles it, and it usually works, because it is singular and visible.

Second: may your role perform this operation? That is function-level authorization, described by OWASP as BFLA. Failures here mean hiding a button instead of blocking an endpoint.

Third: may you perform this operation on this specific object? That is object-level authorization. A correct answer requires considering subject, object and action together. “A user may download an invoice” is not a policy. The policy is “a user may download an invoice they own, or one belonging to their organisation, unless it is marked hidden”.

Most IDOR findings are situations where the code answered the first and second question and skipped the third.

Four myths that keep this alive

Myth one: UUIDs are enough. A random identifier makes guessing harder, but it is not an access control. Identifiers leak constantly and entirely legitimately: in lists returned to other users, in CSV exports, in email bodies, in the Referer header, in proxy logs, in links shared over chat, in ticketing systems. Once an identifier leaks — and it will — the only remaining barrier is the server-side check. Randomness changes the attack class from enumeration to use of a single reference, and that is the whole difference.

Myth two: the UI does not show that option. The UI is a hint, not a boundary. Every endpoint is reachable directly, and an attacker does not use your frontend.

Myth three: the identifier is encrypted. Usually it is not encrypted, merely encoded or hashed in a predictable way. Even if it genuinely is encrypted, the user receives ciphertexts for their own objects, and the moment they see someone else’s anywhere, you are back at the starting point. Obfuscating references is sometimes a useful addition, never a replacement for a check.

Myth four: the tenant is in the token. Excellent — but does the database query contain a condition on that tenant? In many deployments the organisation identifier is carefully verified in the token and never reaches the WHERE clause. The token knows who you are. The query returns someone else’s row anyway.

Variants that are easy to miss

The basic case — swapping an identifier in a resource path — is usually tested. The interesting things happen elsewhere.

Methods other than read. It happens that GET /orders/{id} is protected while PATCH on the same resource is not, because it landed in a different controller written by a different team. Test every method separately, not just reads.

Identifiers in request bodies and nested fields. Object references hide deep: a shipping address identifier, a payment method identifier, an attachment identifier inside an array. The controller validates the top-level object and copies child fields without an ownership check.

Bulk operations. An endpoint accepting a list of identifiers for deletion or export is often checked only for the first element, or only at the level of “may this user perform bulk operations at all”.

Exports, reports and previews. Paths that generate a PDF, email a document or build a preview tend to skip checks because they started as “internal features” and were exposed later.

Files and attachments. The file layer often lives alongside the data layer. The object is protected while the file sits at a predictable object-storage address, or behind a signed URL with too long a lifetime and too broad a scope.

GraphQL. A single node fetched by global identifier plus nested relations create many paths to the same object. A check placed on one resolver protects none of the others.

Owner assignment on creation. If a create request accepts an ownerId or organizationId field and copies it without verification, the attacker does not read someone else’s data — they insert their own objects into someone else’s organisation. That is sometimes worse than a read, because it establishes a persistent foothold in the victim’s system.

Multi-step flows. The check sits in step one, while state is carried into step two by an identifier that nobody validates again.

The fix pattern: authorization closer to the data

The most durable fix is not adding a condition in the controller, because that condition then has to be added in every new controller. The durable fix makes it impossible to fetch an object without subject context.

In practice that means a data access layer that does not expose a “get by identifier” method. Instead there is “get by identifier for this subject”, and the query always carries an ownership or organisation condition. Code that wants to bypass the rule must do so explicitly, and such an exception is visible in review.

The second pillar is a central policy decision. Rules described in one place — as policy functions, a rules engine or a relationship-based permission model — can be tested and audited. Rules scattered across controllers cannot even be inventoried.

The third, very effective element is a database-side safeguard: row-level security that filters data by tenant identifier regardless of what the application did. That is a safety net for coding mistakes, not a replacement for application checks.

Operation order deserves attention too. The pattern “fetch object, perform side effect, then check permissions” shows up in asynchronous and queue-based code. The check must precede every side effect, including sending a notification, which can leak data all by itself.

Testing methodology

Testing object-level authorization needs preparation that a scanner cannot do for you, because a tool does not know who owns what.

Accounts. You need at least two accounts in every relevant role plus two separate organisations. Account A and account B in the same organisation reveal ownership flaws. Organisations X and Y reveal tenant isolation flaws. Without that matrix some results are simply undecidable.

Inventory. Collect endpoints together with methods and every place object references appear — path, body, query parameters, headers. An OpenAPI specification is a good starting point, but verify it against real traffic, because the most interesting paths tend to be undocumented.

Case matrix. For every endpoint–method pair, issue the same request as account A against account B’s object, as an account from organisation X against an organisation Y object, and as a lower-privileged account against an object requiring more. Record status code, response size and the meaningful fragment of the body.

Differences, not just codes. A 200 with an empty list, a 200 with partial data and a 403 are three different states. Sometimes the object is not returned yet the operation still executed — so for mutating methods, verify the side effect on the victim account rather than reading the response body.

403 versus 404. Returning 404 for other people’s objects limits enumeration but complicates diagnosis. What matters more is consistency: if the application returns 404 for a non-existent object and 403 for someone else’s, it confirms the existence of resources the user should not see. That difference alone is an information leak.

Minimal evidence. In the report, limit proof to what is necessary — an object identifier and a single field confirming foreign ownership. Full record dumps containing personal data create a second problem, this time for the client.

Manual work runs out of steam quickly, so automate it: a script that replays account A’s captured traffic with account B’s token and diffs the responses catches most cases, leaving a short list of differences that genuinely need human judgement.

Detection and telemetry

IDOR is detectable in logs, provided the logs contain the right fields. The minimum set is: subject identifier, organisation identifier, object type and identifier, action, and the authorization decision. An HTTP status alone will not do, because it does not distinguish “object does not exist” from “access denied”.

On that data, simple and effective rules work. A sharp rise in denials for one subject indicates enumeration. Access to a number of distinct objects far outside the norm for a role indicates data harvesting. References to identifiers outside the subject’s organisation should alert regardless of whether they succeeded — a successful denial is still evidence of an attempt. Sequential identifiers across consecutive requests are a classic scanning pattern.

It is also worth measuring the success-to-denial ratio per endpoint. An endpoint where denials essentially never occur is either well designed or checking nothing at all, and a quick test settles which.

Preventing regressions

Object-level authorization breaks during changes, not at first release. A new endpoint, a repository refactor, a new relation in the model — each can open a path that bypasses the check.

One practice reliably works: every endpoint that returns or modifies an object gets a negative test in CI, in which an unauthorised subject receives a denial. The test costs a few minutes to write and catches an entire class of regressions. Complement it with a code review that asks a single control question: how does this code know the user is entitled to this object?

Sharing, invitations and time-bound access

Features that deliberately widen access deserve separate attention: share links, organisation invitations, guest access, client previews, delegation during absence. Each creates a second authorization path alongside the primary one, and the second path tends to be written faster and reviewed less.

The failures in this group repeat. A share link grants access to an object but does not constrain the action — the recipient was meant to view, and can modify. An invitation is processed through an identifier that can be swapped, so the invitee lands in a different organisation than intended. Time-bound access is granted but never expires, because expiry is checked when rendering a list and not when fetching the object. Revoking access removes an entry in the UI but does not invalidate a previously generated link.

The design rule here is simple: a share is an object in the data model, not an exception in code. It has an owner, an action scope, an expiry, a usage history and a revocation path. Checking that object happens in the same place as checking ownership — in the data access layer, not in whichever controller handles sharing.

For testing, cover four scenarios: using a link after expiry, using a link after revocation, attempting an action beyond the share’s scope, and using a link from an account in another organisation. Each corresponds to a real incident that has already happened to plenty of companies.

Checklist

Every object reference is authorised server-side, independent of the UI. The data access layer does not expose fetching by identifier alone. Queries carry a tenant condition and the database has row-level security as a safety net. ownerId and organizationId from the request are ignored or verified, never copied. Bulk operations authorise every element of the list. Files and signed URLs have short lifetimes and narrow scope. Mutating methods are tested separately from reads. Responses to unauthorised access are consistent. Logs contain subject, object, action and decision. Every endpoint has a negative test in CI.

Conclusion

BOLA stays at the top of risk lists not because it is hard to fix, but because it is easy to miss in one place out of a hundred. Scanners do not see it, because they do not know the ownership model. Unit tests do not see it, because they usually cover the happy path. Code review does not see it when it looks at a controller without the query context.

The answer is to move the check somewhere it cannot be bypassed by accident: into the data layer, into a central policy, and into the database. Everything else — tests, telemetry, consistent status codes — exists to detect the case where someone bypassed it anyway.


Primary sources: OWASP API Security Top 10 2023 — API1:2023 Broken Object Level Authorization, CWE-639: Authorization Bypass Through User-Controlled Key, OWASP — Authorization Cheat Sheet.

SHARE / COPY