Safeguard
Security Guides•

What is the two-user test for object-level authorization in a PR?

The two-user test asks for another user's object and expects a refusal. How to review for it, automate it, and scope queries to the caller in a PR.

Safeguard Security Team
4 min read

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

  1. Does every new route that takes an id also take the caller's identity into the lookup?
  2. Is there a two-user test for each verb?
  3. Do list and export endpoints filter by owner, not just detail endpoints?
  4. 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.
  5. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.