Web Security (OWASP Top 10:2025) Module 2 – A01:2025 Broken Access Control
IDOR and object-level authorisation (BOLA)
Why changing an id in a request can expose another user's record, why random ids do not fix it, and how scoped queries and two-user tests close the gap.
What you will learn
- Explain how an insecure direct object reference arises, and why unguessable identifiers do not fix it
- Limit each data query to the caller's own records or tenant, and route object access through one shared function
- Protect update, delete, bulk and nested-resource routes as carefully as single reads
- Write two-user tests that try each object through every route that takes its id
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how an insecure direct object reference arises, and why unguessable identifiers do not fix it
- Limit each data query to the caller's own records or tenant, and route object access through one shared function
- Protect update, delete, bulk and nested-resource routes as carefully as single reads
- Write two-user tests that try each object through every route that takes its id
Before you start
On this page
Ravi reads his own note at /notes/2, changes the 2 to a 5, and receives Asha’s medical report. No password was
guessed and nothing was broken into: the server took an id from the request and returned the record it named. MITRE
files this as CWE-639, rank 24 of the 2025 CWE Top 25, and OWASP’s API Security Top 10 puts it first, as
broken object level authorization (BOLA): The first risk of the OWASP API Security Top 10 (API1:2023): an endpoint acts on an object named by an id from the client without checking the caller's right to that object..
The fix is not to hide the 5. It is to make every query that reads, changes or deletes a record ask for the caller as well as the id, in one shared place, and to keep a test that tries each record as a second user. This lesson does both, on notes, bulk reads and nested routes whose two ids come from two different customers.
An id in the request is a question, not a permission
A web app names records by identifiers, and the identifiers travel in requests: in the path (/notes/5), the query
string (?account=5), a form field, a header or a file name. That is how the app finds what you asked for. The
weakness starts when the server treats the identifier as the decision. OWASP’s IDOR Prevention Cheat Sheet describes
an insecure direct object reference (IDOR): A request names a record by an identifier the user controls, such as /notes/5, and the server uses that record without checking that the caller may. MITRE files it as CWE-639. as three things together: an object, a reference to it that
the user controls, and a missing object-level authorisation check. The first scenario of A01:2025 is the same thing in
an account page: an account number from the request goes straight into the query.
The two users here have the same role, which is why CWE-639 also calls this horizontal authorisation: Ravi is not trying to become an admin; he is reading another customer’s records with his own customer rights. Step through his request for note 5:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- An id in the address. Ravi reads his own note at /notes/2. He changes the 2 to a 5 and sends the request with his session cookie. Nothing in the address says whose note 5 is.
- The weak query. The API takes the id from the address and makes it the whole condition of the query. The session says Ravi, but the query never asks for him.
- A row comes back. Note 5 exists, so the database returns it, Asha's medical report. The query did exactly what it was told.
- 200 to Ravi. The API sends the note to Ravi. Trying ids 1 to 8 the same way would give him all eight notes, as the program's last table shows.
- The scoped query. In the fixed API the caller from the session is part of the condition. The database is asked for note 5 owned by user 2.
- No row. No note matches both conditions, so no row comes back. The API never held Asha's note, so no later line of code can leak it.
- 404, as for a missing note. The API answers 404 Not Found, the same answer it gives for note 9, which does not exist. Ravi cannot tell a note that is not his from one that is not there.
The program behind the figure is a database of eight notes owned by three users. Its first query is the weak one, its second the fixed one, and its last counts how many notes Ravi can read by trying ids 1 to 8 under each:
-- Eight notes of three users. The request is GET /notes/5 from ravi (user 2); note 5 belongs to asha (user 1).
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE notes (id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL REFERENCES users (id), title TEXT NOT NULL);
INSERT INTO users VALUES (1, 'asha'), (2, 'ravi'), (3, 'kiran');
INSERT INTO notes VALUES
(1, 1, 'Rent agreement'), (2, 2, 'Gym plan'), (3, 3, 'Exam dates'), (4, 2, 'Tax notes'),
(5, 1, 'Medical report'), (6, 3, 'Trip budget'), (7, 1, 'Recipe'), (8, 3, 'Reading list');
-- What the app binds to the query's placeholders: the id from the URL, the caller from the session.
CREATE TABLE request (note_id INTEGER, caller_id INTEGER);
INSERT INTO request VALUES (5, 2);
-- Weak: the id from the URL is the whole condition (WHERE id = ?).
SELECT 'weak' AS query, n.id, n.title, u.name AS owner
FROM notes n JOIN users u ON u.id = n.owner_id
WHERE n.id = (SELECT note_id FROM request);
-- Fixed: the caller is part of the condition (WHERE id = ? AND owner_id = ?), so another user's note is not found.
SELECT 'fixed' AS query, COUNT(*) AS rows_found
FROM notes n
WHERE n.id = (SELECT note_id FROM request) AND n.owner_id = (SELECT caller_id FROM request);
-- How many notes can ravi read by trying every id from 1 to 8?
SELECT 'weak' AS query, COUNT(*) AS notes_ravi_can_read FROM notes
UNION ALL
SELECT 'fixed', COUNT(*) FROM notes WHERE owner_id = (SELECT caller_id FROM request); Output
┌───────┬────┬────────────────┬───────┐ │ query │ id │ title │ owner │ ├───────┼────┼────────────────┼───────┤ │ weak │ 5 │ Medical report │ asha │ └───────┴────┴────────────────┴───────┘ ┌───────┬────────────┐ │ query │ rows_found │ ├───────┼────────────┤ │ fixed │ 0 │ └───────┴────────────┘ ┌───────┬─────────────────────┐ │ query │ notes_ravi_can_read │ ├───────┼─────────────────────┤ │ weak │ 8 │ │ fixed │ 2 │ └───────┴─────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < scoped_query.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The weak query returns Asha’s medical report, and trying every id gives Ravi all 8 notes. The fixed query finds 0 rows
for note 5, and Ravi can read only his own 2. The rows the program creates stand for the data an app would hold; the
request table stands for the two values the app would bind to the query’s placeholders, the id from the URL and the
caller from the session.
Be Asha
Press Edit and change the request row to (5, 1), Asha asking for her own note, then run it. The fixed query now
finds 1 row, and her count of readable notes is 3: the scoped query still works for the owner.
Checkpoint
Check: What makes a direct object reference insecure
Put the caller into the query
The surest fix makes another user’s record impossible to load at all. There are three ways to get there, best first:
- Scope the query. Add the owner, or the tenant, to the condition:
WHERE id = ? AND owner_id = ?, with the owner taken from the session. Another user’s note is then simply not found, and no later line of code can leak what was never loaded. Frameworks make this the natural form: the cheat sheet’s Rails example looks the project up through the current user’s own projects rather than through all of them. - Load, then check in one function. When the rule is richer than ownership (support may read the notes of their
own customers), load the record and pass it to one shared function,
can(user, 'note:read', note), that every route calls. One function means one place to read, test and fix. - Enforce it below the routes. A repository layer that adds the tenant filter to every query, or the database’s
own row-level security: A database feature that filters which rows each query may read or change by a policy, so that a query which forgets the filter still sees only the rows the policy allows., filters rows even for a query that forgot. In
PostgreSQL, a table with row security enabled and no policy shows no rows at all, a default deny; note that the
table’s owner bypasses it unless the table is set to
FORCE ROW LEVEL SECURITY.
Notice what the list does not contain. API1:2023 warns that comparing the session’s user id with the id in the request solves only a small subset of cases: the record named by the id usually belongs to someone, and that someone is what must be compared. In a multi-tenant: One customer organisation of a shared application. Its users and data must stay apart from every other tenant's, even when they live in the same tables. app the same rule applies one level up, and ASVS 5.0.0 requirement 8.4.1 asks for cross-tenant controls, so that a user’s operations can never affect a tenant they have no permission to work with.
Checking the user, not the record
if (req.params.userId !== session.userId) return 403 protects /users/:userId/notes, but not /notes/:id, whose id
names a note, not a user. Compare the record’s owner with the caller, or scope the query, on every route that takes an
id.
The three fixes side by side, with what each one needs in order to work:
| Fix | What it stops | What it needs |
|---|---|---|
| Scoped query | Another user’s row is never loaded | The owner or tenant on every query |
| One shared check | Rules richer than ownership | Every route to call it |
| Row-level security | A query that forgot the filter | A connection role the policy applies to |
Key idea
Make the caller part of how a record is found: a query that asks for “note 5 owned by Ravi” cannot return Asha’s note, however the id was chosen.
Checkpoint
Check: The caller in the query
Random ids are not authorisation
Switching from 5 to 9f1c2e7a-… makes ids hard to guess, and that is worth having. It is not a check. Identifiers
leave the app all the time:
| Where an id escapes | How |
|---|---|
| Shared links and screenshots | A colleague forwards the address of a note, and it stays in their chat history |
| Logs and analytics | Request paths are logged by servers, proxies and monitoring tools |
The Referer header |
Browsers send a page’s full address to its own site, and to other sites under a loose policy such as unsafe-url |
| Other API responses | A list, an export or an error message returns ids of records the caller may not open |
| Browser history | Anyone who uses the same computer can read the addresses visited |
The IDOR cheat sheet is explicit: unpredictable identifiers are a layer of defence in depth, and access must still be denied whether an identifier is predictable or not. API1:2023 recommends random, unpredictable record ids too, as one measure beside the check, never instead of it.
There is one case where an unguessable token is the permission on purpose: a capability link: A link whose unguessable token is itself the permission, such as a share link. It is meant to be passed on, so it needs a narrow scope, an expiry and a way to revoke it., such as “anyone with this link can view”. That is a feature, designed to be passed on, so it gets what any permission needs: a narrow scope (view, not edit), an expiry, and a way for the owner to revoke it. A database id that happens to be random has none of those.
Checkpoint
Check: Random ids and share links
Every route that takes an id: update, delete, bulk and nested
The read route is often where the check is written first, and the routes written later are where it goes
missing. In this program the bulk endpoint, GET /notes?ids=2,5,9, checks one id and returns many. Step through it and
watch which ids the weak version ever compares with the caller:
Program · bulk/bulk_ids.py
owners = {1: "asha", 2: "ravi",
4: "ravi", 5: "asha"}
caller, ids = "ravi", [2, 5, 9]
# weak: one check, on the first id
if owners.get(ids[0]) == caller:
weak = [i for i in ids
if i in owners]
else:
weak = []
print("weak found", weak)
# fixed: every id is compared
found, missing = [], []
for i in ids:
if owners.get(i) == caller:
found.append(i)
else:
missing.append(i)
print("fixed found", found,
"missing", missing) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yetweak found [2, 5] fixed found [2] missing [5, 9]
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 26 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
owners = {1: "asha", 2: "ravi", 4: "ravi", 5: "asha"}runs. - Line 1.
owners = {1: "asha", 2: "ravi", 4: "ravi", 5: "asha"}makesownersa dict with 4 keys. - Line 3.
caller, ids = "ravi", [2, 5, 9]setscallerto'ravi'and setsidsto[2, 5, 9]. - Line 6.
owners.get(ids[0]) == calleris true (calleris'ravi',ownersis{1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}andidsis[2, 5, 9]), so the block underif owners.get(ids[0]) == caller:runs. The bug The weak version's only check compares the owner ofids[0], Ravi's own note 2, with the caller. It passes, and no later line looks at an owner again. - Line 7.
weak = [i for i in ids if i in owners]runs. - Line 7.
weak = [i for i in ids if i in owners]runs. - Line 7.
weak = [i for i in ids if i in owners]runs. - Line 7.
weak = [i for i in ids if i in owners]runs. - Line 7.
weak = [i for i in ids if i in owners]runs. - Line 7.
weak = [i for i in ids if i in owners]runs. - Line 7.
weak = [i for i in ids if i in owners]setsweakto[2, 5]. - Line 11.
print("weak found", weak)printsweak found [2, 5]. Key pointweakholds every id that exists, 2 and 5, so Asha's note 5 goes back to Ravi. - Line 14.
found, missing = [], []setsfoundto a new list with the same items asmissing,[]and setsmissingto a new list with the same items asfound,[]. - Line 15.
for i in ids:takes the item at position 0 ofids:iis2. - Line 16.
owners.get(i) == calleris true (calleris'ravi',iis2andownersis{1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}), so the block underif owners.get(i) == caller:runs. - Line 17.
found.append(i)adds2to the end offound. - Line 15.
for i in ids:takes the item at position 1 ofids:iis5. - Line 16.
owners.get(i) == calleris false (calleris'ravi',iis5andownersis{1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}), so the else block runs. The fix The fixed loop compares the owner of every id. Note 5 belongs to Asha, so this test is false. - Line 19.
missing.append(i)adds5to the end ofmissing. Key point Note 5 goes tomissing, and note 9, which does not exist, will join it. Not yours and not there land in the same list. - Line 15.
for i in ids:takes the item at position 2 ofids:iis9. - Line 16.
owners.get(i) == calleris false (calleris'ravi',iis9andownersis{1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}), so the else block runs. - Line 19.
missing.append(i)adds9to the end ofmissing. - Line 15.
for i in ids:finds no item at position 3 ofids(it has 3 items), so the loop ends. - Line 20.
print("fixed found", found, "missing", missing)prints nothing. - Line 20.
print("fixed found", found, "missing", missing)printsfixed found [2] missing [5, 9]. That was the last line, so the program ends.
The weak version compares only ids[0], Ravi’s own note 2, and then keeps every id that exists, Asha’s note 5
included. The fixed loop compares each id and puts Asha’s note 5 and the missing note 9 in the same list, missing,
so the answer says nothing about which of them exists.
Nested routes carry two ids, and the link between them needs its own check. Here Ravi, a member of org 1, asks for
/orgs/1/projects/2, and project 2 belongs to org 3:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two ids. The path names an organisation and a project. Each id came from the request, so each needs its own check, and so does the link between them.
- The parent is checked. The handler checks the first id. Ravi is a member of org 1, so the check passes.
- The child is loaded alone. The weak handler then loads project 2 by its own id. Nothing compares the project's organisation with the one in the path.
- Another tenant's data. Project 2 belongs to org 3, where Ravi is not a member, and the weak handler returns it with 200. The check that passed was about a different object.
- The fixed handler. The fixed handler compares project 2's organisation, 3, with the one in the path, 1. They differ, so it answers 404, as for a project that does not exist.
- One query for both. The same rule as one query reads the project by both ids, WHERE id = 2 AND org_id = 1, and finds nothing. Every level of a nested path is checked against its parent.
# GET /orgs/<org_id>/projects/<project_id> in a two-tenant app. ravi is a member of org 1 only;
# project 2 belongs to org 3. The weak handler checks the org in the path, then loads the project by its id alone.
members = {1: {"ravi", "asha"}, 3: {"kiran"}}
projects = {1: {"org": 1, "name": "Website"}, 2: {"org": 3, "name": "Payroll export"}}
def get_project_weak(caller, org_id, project_id):
if caller not in members.get(org_id, set()):
return 404, None
project = projects.get(project_id) # loaded by its own id: its org is never compared
return (200, project["name"]) if project else (404, None)
def get_project_fixed(caller, org_id, project_id):
if caller not in members.get(org_id, set()):
return 404, None
project = projects.get(project_id)
if project is None or project["org"] != org_id: # the child must belong to the parent in the path
return 404, None
return 200, project["name"]
for org_id, project_id in [(1, 1), (1, 2), (3, 2)]:
path = f"/orgs/{org_id}/projects/{project_id}"
weak = get_project_weak("ravi", org_id, project_id)
fixed = get_project_fixed("ravi", org_id, project_id)
print(f"ravi GET {path:<19} weak {weak[0]} {weak[1] or '':<15} fixed {fixed[0]} {fixed[1] or ''}".rstrip()) Output
ravi GET /orgs/1/projects/1 weak 200 Website fixed 200 Website ravi GET /orgs/1/projects/2 weak 200 Payroll export fixed 404 ravi GET /orgs/3/projects/2 weak 404 fixed 404
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nested_resources.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The weak handler checks the organisation in the path and then loads the project by its own id, so Ravi gets org 3’s
payroll export with 200. The fixed handler compares the project’s organisation with the one in the path and answers
404. The same gap appears wherever a record is reached by a second route: an export or download, an “update many”
endpoint, a background job that takes an id from a queue, a GraphQL field that reaches the same object from two
parents. A01:2025 names the plainest case directly, an API missing checks on POST, PUT and DELETE.
Checkpoint
Check: Update, delete, bulk and nested routes
Not yours and not there get the same answer
When Ravi asks for Asha’s note, the fixed API answers 404, exactly as it does for a note that does not exist. A 403
for one and a 404 for the other would let him map which ids exist by comparing answers, even though no note was
sent. The IDOR cheat sheet makes this point about the load-then-check pattern: an explicit ownership check after
loading can reveal that a record exists, so map both cases to the same public response. A scoped query does this by
itself, because it cannot tell the two cases apart either.
| Ravi asks for | Load, then check | Scoped query |
|---|---|---|
| Note 2, his own | 200 | 200 |
| Note 5, Asha’s | 403 | 404 |
| Note 9, not there | 404 | 404 |
The middle column is the leak: one difference of status code is enough to sort the ids that exist from those that do not. Make the two answers alike in every way a client can observe: the status code, the body, and, as far as you can, the time taken. Then watch for the pattern itself. One account asking for dozens of ids it cannot open is rarely an accident, and A01:2025 asks that access-control failures be logged and that repeated failures alert someone, which turns a quiet search through ids into an alert.
Checkpoint
Check: The same answer for not yours and not there
Two-user tests, generated from the routes
A dependable regression test for this weakness needs two accounts. The IDOR cheat sheet describes it: create two users with records of their own, sign in as one, try the other’s references, and do it for every operation that takes a reference: reading, updating, deleting, exporting, and admin functions. API1:2023 adds the release rule: write tests that probe the authorisation mechanism, and do not deploy a change that makes them fail.
Write the routes that take an id in one table, and generate the tests from it, so that a new route is tried as the second user the day it is added. This program does that for four routes of a notes API:
// Two-user tests generated from the route table: asha owns note 5, and ravi tries it through every route that
// takes a note id. Each attempt must answer 404 and leave asha's note as it was.
const freshNotes = () => new Map([[2, { owner: 'ravi', text: 'Gym plan' }], [5, { owner: 'asha', text: 'Medical report' }]]);
// The weak app: the read checks the owner, the routes written later do not, and the bulk read checks the first id only.
const weak = {
'GET /notes/:id': (notes, caller, id) => (notes.get(id)?.owner === caller ? 200 : 404),
'PUT /notes/:id': (notes, caller, id) => (notes.has(id) ? ((notes.get(id).text = `edited by ${caller}`), 200) : 404),
'DELETE /notes/:id': (notes, caller, id) => (notes.delete(id) ? 200 : 404),
// The bulk read is asked for ids=2,<id>; 200 here means that note <id> came back in the answer.
'GET /notes?ids=': (notes, caller, id) => (notes.get(2)?.owner === caller && notes.has(id) ? 200 : 404),
};
// The fixed app: every route loads the note through one shared function that includes the caller.
const ownNote = (notes, caller, id) => (notes.get(id)?.owner === caller ? notes.get(id) : null);
const fixed = {
'GET /notes/:id': (notes, caller, id) => (ownNote(notes, caller, id) ? 200 : 404),
'PUT /notes/:id': (notes, caller, id) => {
const note = ownNote(notes, caller, id);
if (!note) return 404;
note.text = `edited by ${caller}`;
return 200;
},
'DELETE /notes/:id': (notes, caller, id) => (ownNote(notes, caller, id) ? (notes.delete(id), 200) : 404),
// Every id of the bulk read goes through ownNote, so note <id> comes back only when it is the caller's.
'GET /notes?ids=': (notes, caller, id) => (ownNote(notes, caller, id) ? 200 : 404),
};
for (const [label, app] of [['weak app', weak], ['fixed app', fixed]]) {
const failures = [];
for (const [route, handle] of Object.entries(app)) {
const notes = freshNotes();
const status = handle(notes, 'ravi', 5);
const after = notes.get(5)?.text ?? '(deleted)';
if (status !== 404 || after !== 'Medical report') failures.push(`${route} as ravi on note 5: ${status}, note now "${after}"`);
}
console.log(`${label}: ${Object.keys(app).length} routes tried as ravi, ${failures.length} failing`);
for (const f of failures) console.log(` ${f}`);
} Output
weak app: 4 routes tried as ravi, 3 failing PUT /notes/:id as ravi on note 5: 200, note now "edited by ravi" DELETE /notes/:id as ravi on note 5: 200, note now "(deleted)" GET /notes?ids= as ravi on note 5: 200, note now "Medical report" fixed app: 4 routes tried as ravi, 0 failing
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node two_user_tests.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Against the weak app, 3 of the 4 routes fail: Ravi edits Asha’s note, deletes it, and reads it through the bulk endpoint, while the single read, the route everyone tested by hand, passes. Against the fixed app, where every route loads notes through one shared function that includes the caller, nothing fails. Each failure line names the route, the status and what happened to Asha’s note, which is what a failing build should tell you.
Checkpoint
Check: Two-user tests from the route table
Practise: five ways into a note
Five functions take an id from the request: get, update, delete, a bulk read and a nested read. The starter trusts every id. Make each one treat another user’s record exactly like a record that does not exist, and run the sample tests, which act as two users:
Exercise · Medium · JavaScript, Python
Five ways into a note, each checked for its owner
Five functions reach a note or a project by an id that came from the request. Each takes the store and the caller, the name of the signed-in user from the session. The store holds:
notes: id →{ owner, text }, for example5: { owner: 'asha', text: 'Medical report' };projects: id →{ org, name }, andmembers: organisation id → the list of its members' names.
The starter trusts the ids. Make every function treat a record that is not the caller's exactly like one that does not exist (JavaScript names first, Python names in brackets):
getNote(get_note): the note, ornull(None).updateNote(update_note): set the note'stextand returntrue(True); for any other note change nothing and returnfalse(False).deleteNote(delete_note): remove the note and returntrue; otherwise remove nothing and returnfalse.getNotes(get_notes):{ found, missing }, two lists of ids in the order asked: the caller's notes infound, every other id, not theirs or not there, inmissing.getProject(get_project): the project, ornull, unless the caller is a member of the organisation in the path and the project belongs to that organisation.
JavaScript · Starter code · notes.mjs
/** The note with this id, or null. */
export function getNote(store, caller, id) {
return store.notes[id] ?? null;
}
/** Changes the note's text: true when it did, false otherwise. */
export function updateNote(store, caller, id, text) {
const note = store.notes[id];
if (!note) return false;
note.text = text;
return true;
}
/** Removes the note: true when it did, false otherwise. */
export function deleteNote(store, caller, id) {
if (!store.notes[id]) return false;
delete store.notes[id];
return true;
}
/** { found, missing } for a list of ids. */
export function getNotes(store, caller, ids) {
if (store.notes[ids[0]]?.owner !== caller) return { found: [], missing: [...ids] };
const found = ids.filter((id) => id in store.notes);
return { found, missing: ids.filter((id) => !found.includes(id)) };
}
/** The project of GET /orgs/:orgId/projects/:projectId, or null. */
export function getProject(store, caller, orgId, projectId) {
if (!(store.members[orgId] ?? []).includes(caller)) return null;
return store.projects[projectId] ?? null;
} The sample tests · notes.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { deleteNote, getNote, getNotes, getProject, updateNote } from './notes.mjs';
const fresh = () => ({
notes: { 2: { owner: 'ravi', text: 'Gym plan' }, 4: { owner: 'ravi', text: 'Tax notes' }, 5: { owner: 'asha', text: 'Medical report' } },
projects: { 1: { org: 1, name: 'Website' }, 2: { org: 3, name: 'Payroll export' } },
members: { 1: ['ravi', 'asha'], 3: ['kiran'] },
});
test('get returns your own note, and null for one that is not yours or not there', () => {
const store = fresh();
assert.equal(getNote(store, 'ravi', 2)?.text, 'Gym plan');
assert.equal(getNote(store, 'ravi', 5), null, "note 5 is asha's");
assert.equal(getNote(store, 'ravi', 9), null, 'note 9 does not exist');
});
test('update changes only your own note', () => {
const store = fresh();
assert.equal(updateNote(store, 'ravi', 5, 'edited'), false);
assert.equal(store.notes[5].text, 'Medical report', "asha's note is unchanged");
assert.equal(updateNote(store, 'ravi', 2, 'Gym plan, week 2'), true);
assert.equal(store.notes[2].text, 'Gym plan, week 2');
});
test('delete removes only your own note', () => {
const store = fresh();
assert.equal(deleteNote(store, 'ravi', 5), false);
assert.ok(5 in store.notes, "asha's note is still there");
assert.equal(deleteNote(store, 'ravi', 4), true);
assert.ok(!(4 in store.notes));
});
test('a bulk read checks every id, and puts not yours with not there', () => {
const store = fresh();
assert.deepEqual(getNotes(store, 'ravi', [2, 5, 9, 4]), { found: [2, 4], missing: [5, 9] });
assert.deepEqual(getNotes(store, 'ravi', [5, 2]), { found: [2], missing: [5] });
});
test('a nested read checks that the project belongs to the organisation in the path', () => {
const store = fresh();
assert.equal(getProject(store, 'ravi', 1, 1)?.name, 'Website');
assert.equal(getProject(store, 'ravi', 1, 2), null, 'project 2 belongs to org 3');
assert.equal(getProject(store, 'ravi', 3, 2), null, 'ravi is not a member of org 3');
}); Python · Starter code · notes.py
def get_note(store, caller, note_id):
"""The note with this id, or None."""
return store["notes"].get(note_id)
def update_note(store, caller, note_id, text):
"""Changes the note's text: True when it did, False otherwise."""
note = store["notes"].get(note_id)
if note is None:
return False
note["text"] = text
return True
def delete_note(store, caller, note_id):
"""Removes the note: True when it did, False otherwise."""
if note_id not in store["notes"]:
return False
del store["notes"][note_id]
return True
def get_notes(store, caller, ids):
"""{"found": [...], "missing": [...]} for a list of ids."""
first = store["notes"].get(ids[0])
if first is None or first["owner"] != caller:
return {"found": [], "missing": list(ids)}
found = [i for i in ids if i in store["notes"]]
return {"found": found, "missing": [i for i in ids if i not in found]}
def get_project(store, caller, org_id, project_id):
"""The project of GET /orgs/<org_id>/projects/<project_id>, or None."""
if caller not in store["members"].get(org_id, []):
return None
return store["projects"].get(project_id) The sample tests · test_notes.py
from notes import delete_note, get_note, get_notes, get_project, update_note
def fresh():
return {
"notes": {2: {"owner": "ravi", "text": "Gym plan"}, 4: {"owner": "ravi", "text": "Tax notes"}, 5: {"owner": "asha", "text": "Medical report"}},
"projects": {1: {"org": 1, "name": "Website"}, 2: {"org": 3, "name": "Payroll export"}},
"members": {1: ["ravi", "asha"], 3: ["kiran"]},
}
def test_get_returns_your_own_note_only():
store = fresh()
assert get_note(store, "ravi", 2)["text"] == "Gym plan"
assert get_note(store, "ravi", 5) is None, "note 5 is asha's"
assert get_note(store, "ravi", 9) is None, "note 9 does not exist"
def test_update_changes_only_your_own_note():
store = fresh()
assert update_note(store, "ravi", 5, "edited") is False
assert store["notes"][5]["text"] == "Medical report", "asha's note is unchanged"
assert update_note(store, "ravi", 2, "Gym plan, week 2") is True
assert store["notes"][2]["text"] == "Gym plan, week 2"
def test_delete_removes_only_your_own_note():
store = fresh()
assert delete_note(store, "ravi", 5) is False
assert 5 in store["notes"], "asha's note is still there"
assert delete_note(store, "ravi", 4) is True
assert 4 not in store["notes"]
def test_a_bulk_read_checks_every_id():
"""and puts not yours with not there"""
store = fresh()
assert get_notes(store, "ravi", [2, 5, 9, 4]) == {"found": [2, 4], "missing": [5, 9]}
assert get_notes(store, "ravi", [5, 2]) == {"found": [2], "missing": [5]}
def test_a_nested_read_checks_the_project_belongs_to_the_org_in_the_path():
store = fresh()
assert get_project(store, "ravi", 1, 1)["name"] == "Website"
assert get_project(store, "ravi", 1, 2) is None, "project 2 belongs to org 3"
assert get_project(store, "ravi", 3, 2) is None, "ravi is not a member of org 3" A hint
Write one helper first, ownNote(store, caller, id) (Python: own_note), that returns the note only when it exists and its owner is the caller, and null otherwise. Then let the first four functions go through it, so that none of them reads store.notes[id] directly. For projects, compare project.org with the organisation id from the path.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself, one question at a time:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is IDOR, and what is BOLA?
An insecure direct object reference is a request that names a record by an identifier the user controls, such as
/notes/5, answered by a server that uses that record without checking that the caller may. Broken object level
authorization, BOLA, is the name the OWASP API Security Top 10 gives the same weakness in APIs, where it is risk API1.
MITRE files it as CWE-639, authorisation bypass through a user-controlled key. The fix is the same under every name:
look records up through the caller, as in WHERE id = ? AND owner_id = ?, or check each loaded record in one shared
function, on every route that takes an id. Random identifiers make guessing harder, but they are not the check.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Senior to staff
Your SaaS keeps 300 customer companies in one shared database, with a tenant_id column on every table. How do you make sure that no query can return another tenant's rows, even when a developer forgets the filter, and how would you prove it?
-
Mid to senior
POST /exports takes a list of invoice ids and queues a job; a worker builds the file hours later and emails a link. How do you make sure the export contains only invoices the requester may see, and that the link reaches nobody else?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What three things together make an insecure direct object reference?
An object, a reference to it that the user controls (in the path, query string, form, header or file name), and a missing object-level authorisation check. Ravi changing /notes/2 to /notes/5 reads Asha's note with his own customer rights. It is CWE-639 and API1 (BOLA) in the API Security Top 10.
-
What is the surest fix for IDOR, and what does it look like in SQL?
Put the caller into the query: WHERE id = ? AND owner_id = ?, with the owner taken from the session. Another user's note is then simply not found, and no later line of code can leak what was never loaded. In a multi-tenant app the tenant belongs in the condition too.
-
Name the three ways to enforce object-level authorisation, best first.
Scope the query with the owner or tenant; load the record and pass it to one shared function such as can(user, 'note:read', note) that every route calls, for rules richer than ownership; or enforce below the routes with a repository filter or database row-level security. In PostgreSQL the table's owner bypasses row security unless FORCE ROW LEVEL SECURITY is set.
-
Do random, hard-to-guess identifiers fix IDOR?
No. They make guessing harder, but ids leak through shared links, logs, Referer headers, other API responses and browser history. They are defence in depth beside the check, never instead of it. A deliberate capability link needs a narrow scope, an expiry and a way to revoke it.
-
Where do object-level checks usually go missing after the read route is fixed?
On update, delete, bulk and nested routes, exports and downloads, and jobs that take an id from a queue. A bulk read that compares only ids[0] returns every id that exists, and a nested route such as /orgs/1/projects/2 must also compare the project's organisation with the one in the path.
-
Why should a request for "not yours" and one for "not there" be answered alike?
A 403 for another user's note and a 404 for a missing one lets a caller sort which ids exist by comparing answers. A scoped query returns 404 for both on its own. Make the two alike in status, body and, as far as you can, timing, and alert on one account asking for dozens of ids it cannot open.
-
What kind of test catches IDOR before release?
A two-user test: create two users with their own records, sign in as one and try the other's references on every operation that takes one (read, update, delete, export, admin). Generate the tests from the table of routes, and a route added tomorrow is tried the day it appears.
Key takeaways
- An id in a request only says which record the caller asked for; the server must still decide whether this caller may use it, on every route that takes one.
- The surest fix puts the caller into the query, so another user’s record is never loaded, with one shared function or row-level security for the rules that are richer than ownership.
- Random identifiers make guessing harder but leak through links, logs and responses; they are defence in depth, never the check, and a deliberate share link needs its own scope, expiry and revocation.
- Update, delete, bulk and nested routes are where checks go missing: compare every id, and check each child against the parent in its path.
- Answer “not yours” exactly like “not there”, and keep two-user tests, generated from the route table, in every build.
Terms in this lesson
- broken object level authorization (BOLA)
- The first risk of the OWASP API Security Top 10 (API1:2023): an endpoint acts on an object named by an id from the client without checking the caller's right to that object.
- capability link
- A link whose unguessable token is itself the permission, such as a share link. It is meant to be passed on, so it needs a narrow scope, an expiry and a way to revoke it.
- insecure direct object reference (IDOR)
- A request names a record by an identifier the user controls, such as /notes/5, and the server uses that record without checking that the caller may. MITRE files it as CWE-639.
- row-level security
- A database feature that filters which rows each query may read or change by a policy, so that a query which forgets the filter still sees only the rows the policy allows.
- tenant
- One customer organisation of a shared application. Its users and data must stay apart from every other tenant's, even when they live in the same tables.
References
- A01:2025 Broken Access Control (OWASP Foundation)
- Insecure Direct Object Reference Prevention Cheat Sheet (OWASP Foundation)
- API1:2023 Broken Object Level Authorization (OWASP Foundation (API Security Project))
- CWE-639: Authorization Bypass Through User-Controlled Key (MITRE)
- 2025 CWE Top 25 Most Dangerous Software Weaknesses (MITRE)
- ASVS 5.0.0, V8 Authorization (8.2.2 and 8.4.1) (OWASP Foundation)
- Referrer-Policy header (MDN Web Docs (Mozilla))
- PostgreSQL documentation: Row Security Policies (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress