API testing

IDOR testing: how pentesters find broken object-level authorization

IDOR testing step by step: prove it with two accounts and a read-back, use a 7-case test table and demo requests, and fix BOLA with a server-side check.

Short answer

IDOR (insecure direct object reference) means a user can reach another user's record by changing an identifier in a request. OWASP's API list calls it BOLA, API1:2023. Testers prove it with two test accounts: account A requests account B's object, and a read-back confirms the effect. The fix is an ownership check on every object access, on the server.

IDOR stands for insecure direct object reference. It means a user can reach another user's record by changing an identifier in a request. OWASP's API list calls the same class of flaw Broken Object Level Authorization (BOLA), API1:2023. It sits under A01:2025 Broken Access Control in the web Top 10.

OWASP's WSTG defines IDOR this way: it "occurs when an application provides direct access to objects based on user-supplied input." This page covers how we test for it, what proof looks like and how to fix it.

What does an IDOR look like?#

An IDOR needs two things: an object identifier the client controls, and a server that does not check who owns the object. The identifier can be a number, a UUID, a slug or a filename. It can sit in the path, the query string, the JSON body, a header or a cookie.

Illustrative IDOR: Harbor Notes

This is a demonstration. No real customer system was tested. Harbor Notes is a fictional app and example.com is a reserved example domain.

Test user A owns note 1042. Test user B owns note 2077. A sends this request with A's own token:

GET /api/v1/notes/2077 HTTP/1.1
Host: api.harbor-notes.example.com
Authorization: Bearer <token-of-test-user-A>

A vulnerable server answers:

HTTP/1.1 200 OK
Content-Type: application/json

{"id": 2077, "owner_id": 88, "title": "B private note", "body": "..."}

The owner_id (88) is B's account, not A's. A safe server answers 403 Forbidden or 404 Not Found.

How do testers prove an IDOR?#

Testers prove an IDOR with two accounts, a controlled request, controls, and a read-back. A single odd response is not proof. Our order is:

  1. Create two test-owned accounts in the same role (A and B). Create known data in each, marked as test data. Get written authorization for the scope first.
  2. Record the baseline. Fetch B's object with B's token. Save the response.
  3. Replay with A's token. Send the same request with A's token. Save the response.
  4. Compare. If A receives B's data, the read is broken.
  5. Test writes safely. For a write, change only B's test record (for example, a title). Then read it back through B's session. A bare 200 OK is not proof: the read-back is.
  6. Run controls. Send a nonexistent ID, A's own ID, B's ID with no token, and B's ID with a token that has a bad signature. Controls that held go in the report.

Evidence

Evidence we keep for an IDOR finding: both requests and responses with timestamps, the object's owner field, the read-back from the owning account, and the control results. Evidence labels are "Observed" for reproduced results.

Where should you look for object references?#

Check every place the client names an object, including routes that nobody tests by hand.

Location Example Why it gets missed
URL path /notes/2077 Obvious, so often checked
Query string ?invoice_id=551 Added later by a different developer
JSON body {"project_id": 12} Hidden from the browser address bar
Header or cookie X-Account-Id: 88 Trusted as if the server set it
Batch and bulk routes POST /notes/bulk-get One ID in the list skips the check
Export and report jobs GET /exports/9001/download Check applies at creation, not download
File and image URLs /files/7f3a.../report.pdf Served by a storage layer without checks
GraphQL arguments note(id: 2077) Authorization set on the query, not the resolver

Which test cases matter most?#

Run each case for read, update, delete and any action verb (share, publish, archive).

Case Request Expected Finding if
Horizontal read A reads B's object 403 or 404 200 with B's data
Horizontal write A edits B's object 403 or 404 B's read-back shows the change
Sibling route A uses /export for B's object 403 or 404 File contains B's data
Method swap A uses PUT where only GET was checked 403 or 405 Change persists
Parameter in body A sends B's project_id in a POST 403 or 404 New object attached to B's project
Nonexistent ID A requests ID 999999999 404 n/a (control)
Anonymous No token, B's ID 401 200

For multi-tenant products, repeat the matrix between two tenants. The multi-tenant isolation testing guide covers that.

What are common mistakes that hide an IDOR?#

These traps hide an IDOR.

  • Trusting the status code. A 200 with an empty body may be a safe empty result. A 404 may be a real object that is hidden only by the front end. Compare bodies and read back.
  • Testing only the ID in the path. The same object may be reachable through a body field.
  • Using one account. You cannot prove cross-account access from one session.
  • Using real customer data. Use test-owned records only.
  • Confusing IDOR with enumeration. A response that differs between "exists" and "does not exist" leaks information, but it is a lesser finding than reading the data.

How do teams fix IDOR?#

Fix IDOR with a server-side ownership check on every object access. Do not rely on hidden IDs, front-end checks or route names.

Put the check in one shared place, such as a policy function or a data-access layer, and pass the caller's identity into every query. In pseudo-code:

function getNote(caller, noteId):
    note = db.find(Note, id = noteId)
    if note is null:
        return NotFound
    if not policy.canRead(caller, note):   # owner, shared-with, or role rule
        return NotFound                    # same answer as missing, no leak
    return note

Better, scope the query so a foreign row never loads:

SELECT * FROM notes
WHERE id = :noteId AND tenant_id = :callerTenantId AND owner_id = :callerId;

Apply the same check to list, search, export, batch and file routes, and add an automated test that runs the two-account matrix on every new route. To avoid leaking which IDs exist, return the same status for "missing" and "not yours".

What should you do after a fix?#

Ask for a retest. The retest repeats the original request pair with the same accounts, checks the controls again, and checks the sibling routes, because a fix on one route can miss another.

If your product serves several customers from one database, test tenant boundaries next. Our SaaS penetration testing service covers that, and the API penetration testing page lists what we need to start. For the wider list of API risks, see the OWASP API Security Top 10 guide.

What does an IDOR finding look like in a report?#

A finding gives the developer what they need to reproduce and fix the issue. This illustrative write-up shows the parts.

Illustrative finding write-up

This is a demonstration. No real customer system was tested. Names and hosts are fictional.

Field Detail
Title Invoice download works for any signed-in user
Label Observed
Route GET /api/v1/invoices/{id}/pdf
Accounts test user A (tenant 1), test user B (tenant 1), created for this test
Result A downloads B's invoice PDF. The file contains the marker B-invoice-001, which shows whose data came back.
Read-back B's session shows the same invoice, unchanged, so the test caused no side effect.
Controls that held The list route returns only the caller's invoices. A request for a missing ID returns 404. A request with no token returns 401.
Fix Check invoice ownership in the download handler as well as in the list handler.

How do you keep IDOR out for good?#

Fixing one route leaves the next one open, so build the check into the way routes are written.

  • One access layer. Route every object fetch through a function that takes the caller.
  • Default deny. A new route with no policy should refuse access.
  • Tests per route. Add a two-account test to the build for every new object type.
  • Review IDs in code review. Any handler that reads an ID from the request needs an ownership line.
  • Logging. Log denied object accesses, and alert on a burst of denials by one user.

Questions

Are random UUIDs enough to prevent IDOR?

No. A random ID makes guessing hard, but IDs leak through links, logs, emails, shared pages and other API responses. The server must still check ownership. Treat unpredictable IDs as defense in depth, not as access control.

Can a scanner find IDOR?

Rarely. A scanner sees one session and does not know which object belongs to whom. Finding IDOR needs two accounts and knowledge of what each user may see.

Does a 403 on the first try mean the endpoint is safe?

Not yet. Testers also try other methods, other routes for the same object, batch and export endpoints, and IDs in the body or headers. One route can check ownership while a sibling route does not.

What is the difference between IDOR and BFLA?

IDOR/BOLA is about which object you can reach. BFLA (Broken Function Level Authorization, API5:2023) is about which function you can call. An admin-only delete route used by a member is BFLA. A member reading another member's note is BOLA.

Sources

  1. OWASP API Security Top 10 2023: API1 Broken Object Level Authorization (checked 9 November 2026)
  2. OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object References (checked 9 November 2026)
  3. OWASP Top 10:2025 Introduction (checked 9 November 2026)