An insecure direct object reference (IDOR) exists wherever your application accepts an identifier from the client and returns or changes the matching record without asking whether this user may touch it. The API world calls the same flaw broken object-level authorisation (BOLA), and it tops the OWASP API Security list for 2023. Preventing it takes an authorisation check on every object access, applied across the whole application and covered by tests.
What an IDOR is made of, and where it hides
The OWASP IDOR cheat sheet breaks an IDOR into three parts: an object (an account, document, ticket, transaction), a reference to it (an ID, UUID, account number, token or slug), and a missing object-level check. The third part is the flaw.
The reference is not always /users/123 in the URL. It can sit in a hidden form field such as user_id, in a query string, in a request header or in a JSON payload. It can also be a filename: if GET /documents/annual-report.pdf works, try another valid document name.
The OWASP API Security entry on BOLA rates exploitability as easy, prevalence as widespread and detectability as easy. API servers usually do not track client state, so they rely on IDs sent by the client to decide which object to load. Its example scenarios:
- A shop-analytics endpoint of the form
/shops/{shopName}/revenue_data.json, combined with a second endpoint that lists all shop names, exposes the sales data of thousands of stores to a simple script. - A vehicle API that accepts a VIN but never checks that the vehicle belongs to the logged-in user.
- A GraphQL
deleteReportsmutation that deletes whatever document ID it receives.
The last case is a delete, so test updates and deletes as carefully as the page that displays the record.
Test with two accounts
Create User A and User B, give each their own objects (documents, tickets, invoices), authenticate as A and replay requests with B's identifiers.
- Create two accounts with different authorisation scopes.
- Create objects owned by each user.
- Log in as User A and capture every request that carries an object reference.
- Swap in User B's references in the path, query, body and headers.
- Confirm the application denies access every time.
Cover read, create, update, delete, export and administrative actions. Do not skip an endpoint because its identifiers look random. The cheat sheet says access must be denied whether the identifier is predictable or unguessable.
Make it a regression test
Once the manual pass works, turn it into code. A sketch in pytest, assuming fixtures that give you a logged-in session for User A and the ID of a document owned by B. Each request must be otherwise valid, with a well-formed body, so that a 400 or 415 from input validation cannot make the test pass for the wrong reason:
import os
import pytest
BASE_URL = os.environ["APP_BASE_URL"] # from your test config
DENIED = 404 # your chosen response for foreign IDs
ENDPOINTS = [
("GET", "/documents/{id}", None),
("PUT", "/documents/{id}", {"title": "changed"}),
("DELETE", "/documents/{id}", None),
("GET", "/documents/{id}/export", None),
("POST", "/documents/{id}/comments", {"text": "hello"}),
]
@pytest.mark.parametrize("method,path,body", ENDPOINTS)
def test_a_cannot_touch_bs_document(method, path, body, session_a, b_document_id):
url = BASE_URL + path.format(id=b_document_id)
r = session_a.request(method, url, json=body)
assert r.status_code == DENIED
Decide deliberately between 403 and 404. A 403 for a foreign ID confirms that the ID exists, which helps an attacker enumerate. Whichever you pick, assert that one code only. Generate the endpoint list from your route table so new endpoints appear automatically, and block deployments when the suite fails.
GraphQL needs two extra cases. Query a foreign ID at every level of the tree to see whether nested resolvers repeat the parent's check. For batch operations like the reportKeys array above, send a request mixing your own IDs with someone else's, and confirm the whole call fails or the foreign item is skipped.
Scope the lookup to the user
Check authorisation for each object a user tries to access, using the mechanism your framework provides, so the check is structural and not something each developer remembers. The most direct pattern is to look objects up inside the set the current user can reach. The Rails version:
# vulnerable, searches all projects
@project = Project.find(params[:id])
# secure, searches projects related to the current user
@project = @current_user.projects.find(params[:id])
The Spring Boot handler in the cheat sheet calls findById(id) and so returns any document to anyone who can guess or obtain an ID. The snippet below is our own illustration, not OWASP's code. It assumes a Spring Data derived query on an ownerId field, and currentUser() stands for your helper that reads the trusted authentication context:
@GetMapping("/documents/{id}")
public Document getDocument(@PathVariable Long id) {
return documentRepository
.findByIdAndOwnerId(id, currentUser().getId())
.orElseThrow();
}
Where you can, avoid exposing identifiers and derive the user from the session. In multi-step flows, keep identifiers in the session so the client cannot tamper with them between steps.
Random identifiers help, but only as defence in depth. Add a random-string column and use it in URLs, or use UUIDs as primary keys. Do not encrypt IDs to hide them; doing so securely is hard.
What belongs in code review
Read every handler that takes a client-supplied ID and find where the permission check happens. Look first at whether the query runs against a user-scoped set or calls a global find and checks afterwards, if at all. Then confirm the check exists in every function that reaches a record from client input, including update, delete and export.
Watch for a handler that only compares the session user ID (for instance extracted from the JWT, which you can inspect with our JWT tool) with the ID parameter. OWASP says this covers only a small subset of cases. Authorisation has to follow user policies and hierarchy. In a multi-tenant system, an admin in tenant X may request an invoice that belongs to tenant Y. The user is authenticated, the role is admin and the ID is valid, so a plain role check can pass while the object sits in the wrong tenant.
Keep two flaws apart when triaging. In BOLA the user is allowed to call the endpoint by design, and the violation is the manipulated ID. If a user reaches an endpoint they should not reach at all, that is broken function-level authorisation, a different fix.
Where to start
- Inventory every endpoint, form and GraphQL operation that accepts an identifier, including filenames and hidden fields. Start from your OpenAPI spec or route table, then add what only shows up in proxy history while you click through the application.
- Run the two-user pass above on the ten most sensitive objects first, such as invoices, personal data and admin actions, and record every response that is not a denial.
- Replace global lookups with user-scoped ones, using your framework's authorisation mechanism.
- Put the parametrised cross-user tests into your pipeline and make a failure block the release.
- Add the review questions to your pull request template. For an outside check of what your tests missed, you can commission our penetration testing service.
