Skip to content

Broken object-level authorization: whose record is this?

The user is allowed on the endpoint; the violation is at the object, so tie every lookup to its owner.

Predict first

Before you read: predict

ExamHall's results API answers GET /attempts/:id/result and returns the attempt if it exists; the endpoint already checks that the caller is signed in. Mia, a candidate like Ben, was sent a shared results URL, so she already has Ben's attempt id. She requests it and sees his score.

Which is the simplest handler that returns Ben his own result and refuses Mia?

Pick one

The 30-second version

Broken object-level authorization is a logged-in user reaching another user's record by its id. A role check passes: both hold the same role, and only the record's own owner tells them apart. Scope every lookup by the caller (WHERE id = $1 AND owner_id = $2), so another's id returns nothing; unguessable ids never replace the check, and a second user's call proves it.

The user is allowed on the endpoint; the violation is at the object, so tie every lookup to its owner.

How it works

Step 1 of 12

An id from the request. The handler takes an object id from the path or query and reads that object, trusting the id.

Analogy: A tyre-fitting centre's stored-tyre counter (motor trade)

Drivers leave their off-season tyres in the centre's store. Any customer may come to the counter and ask for a set; the fitter fetches only a set stored under the asker's own account.

At the tyre counter In the API
any customer may walk up to the counter the user is allowed on the endpoint (step 2)
"I'm a customer" is true of everyone in the queue a role check passes every candidate (step 3)
the fitter looks for the set under the asker's own account the lookup scoped by the caller's owner_id (step 5)
asked for a set stored under someone else, the fitter finds nothing on the asker's account and says "no set of yours" a scoped read returns no row, answered 404 (steps 6–7)
a number plate read off a car in the car park is still refused at the counter an id that leaked is still refused by the check (step 8)
one counter rule every fitter follows, not each fitter's habit one central ownership check (step 10)

In real terms: the endpoint lets the user in; the row's owner decides whether this record is theirs.

Breaks down when: a fitter knows a regular customer by sight. A server knows only the session and the id, so the owner on the record is the only tie it can check.

Examples, run for real

4.1 ExamHall EXH-2420: a classmate reads a result

ExamHall's GET /attempts/:id/result checked only that the caller was signed in and read WHERE id = $1. Mia, a candidate, held Ben's attempt id. MR !2720 scopes the read to the caller:

4.1 before MR !2720 (WHERE id = $1): Mia (candidate) asks for Ben's attempt att-ben -> 200, score 18 · Ben asks for his own -> 200
4.1 after MR !2720 (WHERE id = $1 AND owner_id = <caller>): Mia -> 404 (no row for her id), log "refused caller=mia id=att-ben -> 404" · Ben -> 200, score 18

Both are candidates, so a role check would have passed Mia (step 3). The scoped read returns no row for her, answered the same 404 as a missing id (steps 5–7).

4.2 Payments: a listing that filtered on the client

A payments dashboard's GET /invoices read every tenant's invoices and the page hid the others. A second tenant read the raw response:

4.2 before (SELECT ... ORDER BY id, page hides others): tenant B's /invoices response carries 3 rows (inv-1, inv-2, inv-3), including tenant A's inv-1 and inv-3
4.2 after (WHERE tenant_id = <caller>): tenant B's response carries 1 row (inv-2), its own only

Hiding rows on the client leaves them all in the response (step 9). The fixed query filters by the caller's tenant, so only that tenant's rows are ever sent.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A payments team asks you to review its MR, which touches three routes. Tenant A and tenant B each own their invoices and receipts; support staff hold the support role.

Which route do you send back before merge?

Pick one

Question 2 of 7

ExamHall's autosave route writes a candidate's answers with UPDATE attempts SET answers = $3 WHERE id = $1 AND owner_id = $2, with $2 the caller's user, and answers 200 when the UPDATE reports a changed row, 404 when it reports none. Ben owns att-ben; Mia owns att-mia. Three saves run in order: (1) Ben saves to att-ben; (2) Mia saves to att-ben; (3) Ben saves to att-missing, an id no attempt has.

What does each save get, and whose answers does att-ben hold at the end?

Pick one

Question 3 of 7

In a design round you scope ExamHall's result read by the caller. The interviewer follows up: "The team would rather keep WHERE id = $1 and instead compare the id in the request against a list of the caller's own attempt ids, built from their JWT. Does that fix it?" You give one answer.

Which answer survives the interviewer's next question?

Pick one

Question 4 of 7

ExamHall's result read is now scoped to the caller, and a second candidate's request for Ben's attempt gets 404. The interviewer asks: "The attempt ids are still sequential numbers. Now that the owner check is in, should they become random GUIDs as well?" You give one answer.

Which answer survives the interviewer's next question?

Pick one

Question 5 of 7

The interviewer says: "ExamHall has 200 endpoints that each take an object id. Write your answer in at most 150 words, as a short list.

How do you make this bug hard to write across all of them, and how do you test for it?

Question 6 of 7

A payments team writes three tests for GET /invoices/:id, whose handler runs SELECT * FROM invoices WHERE id = $1 and answers 200 with the row, or 404 if there is none. Each test signs in as a tenant: (1) tenant A reads her own invoice and expects 200; (2) tenant B reads tenant A's invoice and expects anything but 200; (3) tenant B reads an id no invoice has and expects 404.

Which tests go red?

Pick one

Question 7 of 7

ExamHall's shared ownedBy(caller) helper adds AND owner_id = <caller> to the query of each route that calls it, and the detail route GET /attempts/:id/feedback calls it. A new list route, GET /feedback?course=econ, ships reading SELECT id, note FROM feedback WHERE course = $1 without calling the helper. Ben and Mia both take the econ course, and each has one feedback row. Signed in as Mia, she asks (1) the detail route for Ben's attempt and (2) the list route for the econ course.

What does each return?

Pick one

Go deeper

Pro tip
  1. In a review, for every endpoint that takes an object id, ask "what does this return if I send another user's id?" and look for the owner in the query. When: any MR that adds or changes a route reading by id (mr-security-lens). Status: SELF (this lesson's practice target; not run).
Source

Valid as of 2026-10-19.

  • OWASP, API Security Top 10 2023: API1 Broken Object Level Authorization (the row's first source), https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ (owasp-api1-2023.txt): "In the case of BOLA, it’s by design that the user will have access to the vulnerable API endpoint/function." (steps 2–3); "Write tests to evaluate the vulnerability of the authorization mechanism." (step 12). As of 2026-10-19.
  • OWASP, Top 10:2025 A01 Broken Access Control (the row's second source), https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/ (owasp-top10-2025-a01.txt): "Except for public resources, deny by default." (step 10); "Implement access control mechanisms once and reuse them throughout the application" (step 10); "Access control is only effective when implemented in trusted server-side code or serverless APIs, where the attacker cannot modify the access control check or metadata." (step 4); "Developers and QA staff should include functional access control in their unit and integration tests." (step 12); "Log access control failures, alert admins when appropriate (e.g., repeated failures)." (step 11). As of 2026-10-19.
  • OWASP, Authorization Cheat Sheet (an official OWASP page; the IDOR-prevention section the row's WSTG source points to), https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html (owasp-authorization.txt): "Perform access control checks on every request for the specific object or functionality being accessed. Just because a user has access to an object of a particular type does not mean they should have access to every object of that particular type." (steps 2–5); "This ID may be exposed as a query parameter, path variable," and elsewhere (step 8). As of 2026-10-19.

Checked against the tools and docs as of 2026-10-19.