The two-user test creates an object as user A, then tries to read, change or delete it as user B and expects a refusal. Add it to any pull request that fetches an object by id, because a missing ownership check is invisible in happy path testing.
What it catches
OWASP's Insecure Direct Object Reference Prevention Cheat Sheet describes the flaw with three ingredients: an object such as an account, document, ticket or transaction; a reference to it, such as an id, filename or token; and a missing object-level authorization check. Change /users/123 to /users/124 and the app returns someone else's data, because it never checked whether the caller should see it.
The reference does not have to be a number. The same cheat sheet notes that filenames, account numbers and tokens are references too, and that using hard to guess identifiers is defence in depth, not a replacement for the check.
Why reviewers miss it
In a diff the vulnerable code looks normal:
def get_invoice(request, invoice_id):
return Invoice.objects.get(id=invoice_id)
It runs, it returns the right record for the right user, and every test written by the author passes. Nothing in the code is wrong, something is missing. Reading harder does not fix that, a test does.
The test, step by step
OWASP describes the manual version: create User A and User B, create objects owned by each, authenticate as A and try to access B's objects by changing the references, and confirm the application denies access whether the id is predictable or not. It says to check read, create, update, delete, export and administrative actions.
As an automated test:
def test_user_b_cannot_read_user_a_invoice(client, user_a, user_b):
invoice = make_invoice(owner=user_a)
client.login(user_b)
response = client.get(f"/invoices/{invoice.id}")
assert response.status_code in (403, 404)
Repeat for each verb the endpoint supports. Five minutes per endpoint pays back the first time it fails.
What to look for in the fix
OWASP's example for Ruby on Rails shows the pattern. Looking up a project from all projects by the id in the request is vulnerable, while looking it up through the current user's own projects is safe:
# vulnerable, searches all projects
@project = Project.find(params[:id])
# safer, searches projects related to the current user
@project = @current_user.projects.find(params[:id])
The safer form scopes the query to what the caller may see, so an unauthorized id behaves like a missing one. The same idea works in any framework: filter by owner in the query, not in a separate check that someone can forget.
Review questions
- Does every new route that takes an id also take the caller's identity into the lookup?
- Is there a two-user test for each verb?
- Do list and export endpoints filter by owner, not just detail endpoints?
- Does the id also arrive in a body field or a hidden form field? OWASP's example shows a hidden user id in a POST body that can be tampered with.
- Is the identity taken from the session, not from a client supplied value? OWASP recommends determining the current user from session information.
Status codes
Either 403 or 404 can be right. Returning 404 for objects the caller may not know exist avoids confirming that an id is valid. Whichever you choose, apply it consistently so the response does not leak which ids exist.
Quick check
Do random UUIDs remove the need for the check?
No. OWASP says complex identifiers are defence in depth and that if an attacker obtains a link to an unauthorized object, the app must still block access.
Is a role check enough?
A role check says what kind of user is calling. It does not say whether this user may see this object. Both checks are usually needed.
Sources
Last reviewed: 19 October 2026.