# IDOR testing: how pentesters find broken object-level authorization

Source: https://penetrationtestservices.com/guides/idor-testing/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

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:
> 
> ```http
> GET /api/v1/notes/2077 HTTP/1.1
> Host: api.harbor-notes.example.com
> Authorization: Bearer 
> ```
> 
> A vulnerable server answers:
> 
> ```http
> 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 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](/guides/multi-tenant-isolation-testing/) 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:

```text
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:

```text
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](/saas-penetration-testing/) service covers that, and the [API penetration testing](/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](/guides/owasp-api-security-top-10/).

## 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

- [OWASP API Security Top 10 2023: API1 Broken Object Level Authorization](https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/)
- [OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object References](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References/)
- [OWASP Top 10:2025 Introduction](https://top10.owasp.org/2025/0x00_2025-Introduction)
