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?
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
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?
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?
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?
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?
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?
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?
Go deeper
Pro tip
- 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.
Related lessons
Checked against the tools and docs as of 2026-10-19.