IDOR Testing for APIs: How to Find Broken Object Access

IDOR Testing for APIs: How to Find Broken Object Access
Most teams that get hit by an IDOR did not have a guessable ID problem. They had an API that returned an invoice, an export, or a user record to someone who had no business asking for it, and nobody ever checked whether the server asked "is this caller allowed to touch this object?" The ID format was irrelevant. UUIDs do not fix it. Obscurity is not an access control.
That is the frame for this article. IDOR testing is an authorisation check applied to every object reference your API accepts, on every operation, for every role. It is not a hunt for sequential integers. Treat it as the second thing and you will test a handful of obvious endpoints, find nothing, and ship the one route where a developer forgot the ownership check.
Below is a practical method for senior engineers: how to set up the test, where object references hide, how to judge results correctly, and how to stop doing this once a year. Everything here is bounded and defensive. You test your own systems, with accounts you own, and no exploit payloads are needed.
What IDOR actually is (and why it is the same thing as BOLA)
IDOR stands for Insecure Direct Object Reference. The API takes an identifier from the client, looks up the object, and returns or modifies it without confirming the caller is allowed to access that specific object. OWASP's API Security Top 10 calls the same flaw Broken Object Level Authorization (BOLA) and ranks it first on the API list (OWASP API Security Top 10, 2023). IDOR is the older web-app name, BOLA is the API-era name. Same bug, same fix, same test.
The important word is authorisation, not identifier. Authentication answers "who are you?" Object-level authorisation answers "may you act on this particular record?" An API can have flawless login, strong tokens and short sessions and still fail the second question on a single route.
This is why the "unguessable ID" defence misleads teams. A UUID makes enumeration harder, but IDs leak constantly: in emails, webhooks, shared links, logs, support tickets, front-end state and list endpoints. Once one valid ID is in the wrong hands, the only thing standing between the caller and the object is the server-side check. If that check is missing, the bug is live no matter how random the ID looks.
Treat every place where the client names an object as a trust boundary. The server must derive the caller's rights from the session, then compare them against the object's owner, tenant or role grant. If the comparison is absent, or happens after the data is already fetched and serialised, you have a finding.
Why scanners and SAST often miss it
A pattern-based tool needs a pattern. IDOR has no signature. The vulnerable request is syntactically identical to the safe one: a valid, authenticated GET for a valid object ID. Nothing in the payload is malicious. The only thing wrong is that this caller should not receive this object, and that fact lives in your business rules, not in the request.
SAST sees code, not intent. It can flag a string-concatenated SQL query because the dangerous shape is recognisable. It cannot know that getInvoice(id) was supposed to be scoped to the caller's organisation, because the scoping rule is a product decision. A handler that fetches by primary key looks identical whether the ownership check exists three lines up, in middleware, in an ORM scope, or nowhere. Static analysis would need to know which of those is your intended policy. For a closer look at what static analysis can and cannot reach, see SAST and business logic vulnerabilities.
A black-box scanner has the mirror problem. It usually runs as one identity, or none. To detect IDOR it needs two principals, knowledge of which objects belong to which, and an oracle for "this response should have been denied." Generic scanners have none of the three. They will happily report a missing security header while a cross-tenant read goes through untouched.
The result is a false sense of coverage. Your dashboard is green on injection and dependency issues while the bug class that most often exposes customer data goes unchecked. This is a category mismatch more than a tooling failure: the bug needs application context and an attacker's reasoning about who is allowed to do what. It is the same reason autonomous pentesting and vulnerability scanning find different things.
Set up the test: two accounts, separate objects
The minimum viable IDOR test is two principals who own disjoint sets of objects. Call them User A and User B. Ideally they live in different tenants. Create real data under each: invoices, projects, API keys, uploaded files, whatever your product holds.
The method is a swap. Perform an action as User A and record the request, including the object identifiers. Then replay the same request using User B's session and User A's object ID. If User B receives A's data or changes A's state, the object check is missing. If the server returns a denial, it held.
Build the matrix before you start. For each role you ship (owner, admin, member, read-only, service account), you want a representative in each of at least two tenants. Then you can test three distinct relationships:
- Horizontal: same role, different owner (member in tenant A reaching member data in tenant B).
- Vertical: lower role reaching a higher role's objects inside the same tenant (read-only user touching admin-only settings).
- Cross-tenant: the one that usually matters most in B2B SaaS and fintech, covered in more depth in tenant isolation testing.
Keep a plain table: object type, owner, ID, and which endpoints reference it. It feels tedious and pays off immediately, because the table becomes your coverage map and later your regression suite.
Run this in staging with seeded data when you can. If you must test in production, agree the rules first: dedicated test accounts, no real customer objects, clear time windows. Offensive security testing in production covers what to settle before anyone sends a request.
Use two accounts you control. Never test against real customer data.
Find every object reference, not just the ones in the URL path
Engineers test /api/invoices/{id} and call it done. The path parameter is the easiest reference to see and the one most likely to already have a check. The bugs hide in the others.
Walk your API specification, your front-end network traffic and your code, and list every place the client names an object:
- Query strings:
?account_id=,?export_id=,?org=. - Request bodies: a
project_idinside a JSON POST, aparent_idon create, atarget_useron a transfer. - Headers and cookies: tenant selectors like
X-Org-Id, workspace switchers, impersonation hints. - Nested and relational IDs:
/orgs/{org}/projects/{project}. The server may validate the org but never confirm that the project actually belongs to it. - Bulk and list inputs: an array of IDs in a batch endpoint, where one valid ID and one foreign ID get processed together.
- GraphQL arguments and nested fields: a resolver deep in the tree that skips the check the top-level query performed.
- Indirect references: signed URLs, file keys, webhook callback IDs, job and report IDs, pagination cursors that encode an object.
- Secondary endpoints: export, download, preview, thumbnail, audit-log and search routes that return the same object through a side door.
The nested case deserves special attention. A common failure is checking that the caller belongs to the parent but trusting the child ID. The org check passes, so the handler feels safe, and the foreign project comes back anyway.
Code helps here. Grep for handlers that read an ID from the request and fetch by primary key without a scope. Every direct findById(id) that is not paired with an owner or tenant filter is a candidate for a swap test. Prioritise by what the object contains: money, personal data, credentials and anything that can be mutated.
Test every operation, not only reads
A read-only IDOR leaks data. A write IDOR changes it. Teams routinely lock down GET and forget the verbs that do damage, because developers think of "viewing a record" as the sensitive act.
For every object type, run the full lifecycle with the swap method:
- Create: can User B create a child object under User A's parent by supplying A's ID?
- Read: the classic case.
- Update: PUT and PATCH with a foreign ID. Also watch mass assignment, where a body field like
owner_idororg_idsilently reassigns the object. - Delete: can B remove A's record?
- Actions and state changes: approve, cancel, refund, transfer, invite, revoke, rotate key. These custom verbs are where authorisation logic is least standardised.
- Export and share: generating a report or a share link for an object you do not own.
Method confusion causes a lot of misses. A route may enforce ownership on GET /items/{id} but not on DELETE /items/{id}, because the two handlers were written at different times, or the check lives in a decorator that only the read path uses. Test each HTTP method on each route independently. One verb's protection does not cover its siblings.
Be careful with destructive tests. Run deletes and state changes against seeded throwaway objects in a test tenant, and keep a reset script. The goal is to prove the server refuses, not to leave your staging data in pieces.
Pay extra attention to money and identity operations. A cancel-subscription or change-payout-destination endpoint that skips the ownership check is not a data leak. It is direct financial impact. Test those routes first and retest them most often.
Judge the result by content and effect, not status codes
Status codes lie. A 200 does not prove a vulnerability and a 403 does not prove safety. Decide pass or fail by what the response contains and what changed on the server.
For reads, inspect the body. Did User B receive User A's actual fields, or an empty object, or a generic error wrapped in a 200? Some APIs return 200 {"data": null} for a denied object, which is safe. Others return 404 for foreign objects to avoid confirming existence, which is also fine. On the other hand, a 403 can arrive after the handler already fetched and logged the object, or the response may still leak metadata through error text, timing or a count.
For writes, check the effect, not the reply. A 200 OK or a 204 on a PATCH tells you little. Log back in as User A and confirm whether the object changed. A request that returns an error to the caller but still commits the change is a real bug that status-code checking would never see. The reverse also holds: a noisy error with no state change is a pass.
Watch for partial leaks. The response may block the main object but expose related fields: a user's email inside a comment, a project name inside a notification, an aggregate count in a list. Compare the full body against what B should be allowed to see.
Record evidence for every confirmed finding: the two principals, the exact request, the response content, and the state before and after. Evidence is what separates a verified finding from a suspicion, and it lets an engineer reproduce and fix the bug quickly. Verified security findings explains why proof beats inference when a team has to decide what to fix first.
Write the assertion in terms of data and state, never in terms of the status code.
Make IDOR testing continuous instead of annual
An annual pentest can find your IDORs on the day it runs. It cannot see the endpoint your team added in March, the nested resource refactored in June, or the export feature shipped last sprint. Authorisation bugs come from ordinary feature work, so the testing cadence has to match the shipping cadence.
There are three practical layers.
First, turn your two-account swap into automated regression tests. Keep the object table from the setup step, and for each route assert that the foreign principal is denied and that no state changes. Run it in CI against a seeded environment. Every new route gets a row in the matrix as part of definition of done.
Second, review authorisation changes before merge. The risky diffs are the ones touching auth, access control, new endpoints that accept IDs, and billing or permission logic. Borg does PR security review on exactly those changes, with findings inline on the pull request, a check status and optional merge blocking. It reviews the diff plus repository context, which is where a missing ownership check is cheapest to catch. For how that differs from a broader test, see full pentest vs PR security review.
Third, test the running application with code context. Borg runs whitebox pentests against live applications and APIs using source-code context, generates its own user contexts, and returns findings with evidence and reproduction steps. After a fix ships, Borg retests it.
Cadence is the point. Object-level access control gets tested when it changes, not when the calendar says so.
Conclusion
IDOR is an authorisation check you either performed on every object reference or did not. Start this week: build the two-account table, list every place your API accepts an object identifier, and run the swap on your highest-value routes first, meaning anything touching money, credentials or personal data. Then make it repeatable so the next release does not quietly undo it.
If you want that coverage without owning the whole workflow, book a demo at borghq.io. Borg reviews risky PRs for missing access checks before they merge, and pentests your running APIs with source-code context, returning verified findings with reproduction steps. Point it at your auth and object-access paths and see what it confirms in your app.
Frequently asked questions
- What is the difference between IDOR and BOLA?
- They describe the same flaw. IDOR (Insecure Direct Object Reference) is the older web-application term. BOLA (Broken Object Level Authorization) is the name OWASP uses in its API Security Top 10. In both, the server accepts an object identifier without checking that the caller may access that object. The test and the fix are identical.
- How do you test for IDOR vulnerabilities in an API?
- Use two accounts that own separate objects. Capture a request as User A, then replay it with User B's session and A's object ID, across every operation and every place an ID appears. Judge the result by response content and server-side state change, not status codes. Borg automates this for live APIs with evidence and reproduction steps.
- Do UUIDs prevent IDOR?
- No. UUIDs make guessing IDs harder, but IDs leak through emails, logs, shared links, webhooks and list endpoints. Once an attacker holds a valid ID, only the server-side ownership check protects the object. IDOR is a missing authorisation check, so unguessable identifiers reduce exposure but do not remove the vulnerability.
- Why don't SAST tools and scanners catch IDOR?
- IDOR has no code or payload signature. A vulnerable request looks identical to a legitimate one, and whether a caller should see an object is a business rule. Scanners usually run as a single identity, so they lack the second principal needed to test it. Borg targets this gap with application-aware testing and PR review of access-control changes.
- How often should you run IDOR testing?
- Whenever authorisation-relevant code changes, not once a year. New endpoints, nested resources, exports and permission changes can each introduce a missing check. Automate the two-account swap in CI, review risky pull requests before merge, and retest after fixes. Borg's continuous approach is designed to match how often teams ship.



