Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · message by message Ravi asks for note 5, which is Asha's Step through one request with an id that Ravi changed by hand, against the weak query and then against the scoped one.
Ravi asks for note 5, which is Asha's, step 7 of 7. Messages so far: Ravi's app to Notes API: GET /notes/5; Notes API to Database: WHERE id = 5; Database to Notes API: Asha's medical report; Notes API to Ravi's app: 200: Medical report; Notes API to Database: id = 5 AND owner_id = 2; Notes API to Ravi's app: 404 Not Found.Ravi's appsigned in as user 2Notes APIGET /notes/:idDatabaseno row foundGET /notes/5WHERE id = 5Asha's medical report200: Medical reportid = 5 AND owner_id = 2404 Not Found

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. A row comes back. Note 5 exists, so the database returns it, Asha's medical report. The query did exactly what it was told.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

Note 5 for Ravi, by id alone and by id and owner SQL · notes/scoped_query.sql
-- 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

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.

SQLite Online (SQL Playground) Paste the notes table and try the scoped query with other ids and callers.

Checkpoint

Check: What makes a direct object reference insecure

  1. Question 1 of 2 GET /notes/5 returns note 5 to whoever is signed in. Which part makes it an insecure direct object reference?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing checks that the caller may use note 5

    OWASP's IDOR cheat sheet names three parts: an object, a reference the user controls, and a missing object-level check. Only the third is a mistake.

  2. Question 2 of 2 Ravi and Asha are both customers. Ravi reads Asha's note by changing the id. What kind of privilege problem is this?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Horizontal, because he uses his own customer rights on another customer's data

    Most IDOR flaws are horizontal: two users with equal rights, and a server that lets one reach the other's records because it never compares the record's owner with the caller.

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:

  1. 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.
  2. 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.
  3. 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

  1. Question 1 of 2 In scoped_query.sql, the fixed query asks for note 5 owned by user 2. Why does it find 0 rows?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Note 5 is owned by user 1, Asha, so no row has both id 5 and owner 2

    With the caller in the condition, another user's note is simply not found. That is why Ravi can read 2 notes under the fixed query and 8 under the weak one.

  2. Question 2 of 2 A team enables PostgreSQL row-level security on the notes table, with a policy that filters by owner, but the app connects as the role that owns the table. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The policy does not apply to the app's queries, because table owners bypass row security unless the table is set to FORCE ROW LEVEL SECURITY

    Connect as a role the policy applies to, or set FORCE ROW LEVEL SECURITY, and test it: a check that silently does not run is worse than none, because everyone believes it does.

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

  1. One question A team replaces sequential note ids with random UUIDs and closes the IDOR ticket. What is still true?

    Choose one answer.

    Show the answer: One question

    Answer: Anyone who gets a note's address, from a shared link, a log or another response, can still open it

    Unpredictable ids are defence in depth, worth keeping beside the check. The ticket closes when every route that takes an id checks the caller.

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:

Interactive · line by line A bulk read that checks one id of three Step through both versions on ids 2, 5 and 9 for Ravi, and watch which ids are ever compared 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

After line 20: owners is {1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}; caller is 'ravi'; ids is [2, 5, 9]; weak is [2, 5]; found is [2]; missing is [5, 9]; i is 9.owners1:'asha'2:'ravi'4:'ravi'5:'asha'caller'ravi'ids205192weak2051found20missing5091i9
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped

Printed so far

weak 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.

Line run 20
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. owners = {1: "asha", 2: "ravi", 4: "ravi", 5: "asha"} runs.
  3. Line 1. owners = {1: "asha", 2: "ravi", 4: "ravi", 5: "asha"} makes owners a dict with 4 keys.
  4. Line 3. caller, ids = "ravi", [2, 5, 9] sets caller to 'ravi' and sets ids to [2, 5, 9].
  5. Line 6. owners.get(ids[0]) == caller is true (caller is 'ravi', owners is {1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'} and ids is [2, 5, 9]), so the block under if owners.get(ids[0]) == caller: runs. The bug The weak version's only check compares the owner of ids[0], Ravi's own note 2, with the caller. It passes, and no later line looks at an owner again.
  6. Line 7. weak = [i for i in ids if i in owners] runs.
  7. Line 7. weak = [i for i in ids if i in owners] runs.
  8. Line 7. weak = [i for i in ids if i in owners] runs.
  9. Line 7. weak = [i for i in ids if i in owners] runs.
  10. Line 7. weak = [i for i in ids if i in owners] runs.
  11. Line 7. weak = [i for i in ids if i in owners] runs.
  12. Line 7. weak = [i for i in ids if i in owners] sets weak to [2, 5].
  13. Line 11. print("weak found", weak) prints weak found [2, 5]. Key point weak holds every id that exists, 2 and 5, so Asha's note 5 goes back to Ravi.
  14. Line 14. found, missing = [], [] sets found to a new list with the same items as missing, [] and sets missing to a new list with the same items as found, [].
  15. Line 15. for i in ids: takes the item at position 0 of ids: i is 2.
  16. Line 16. owners.get(i) == caller is true (caller is 'ravi', i is 2 and owners is {1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}), so the block under if owners.get(i) == caller: runs.
  17. Line 17. found.append(i) adds 2 to the end of found.
  18. Line 15. for i in ids: takes the item at position 1 of ids: i is 5.
  19. Line 16. owners.get(i) == caller is false (caller is 'ravi', i is 5 and owners is {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.
  20. Line 19. missing.append(i) adds 5 to the end of missing. Key point Note 5 goes to missing, and note 9, which does not exist, will join it. Not yours and not there land in the same list.
  21. Line 15. for i in ids: takes the item at position 2 of ids: i is 9.
  22. Line 16. owners.get(i) == caller is false (caller is 'ravi', i is 9 and owners is {1: 'asha', 2: 'ravi', 4: 'ravi', 5: 'asha'}), so the else block runs.
  23. Line 19. missing.append(i) adds 9 to the end of missing.
  24. Line 15. for i in ids: finds no item at position 3 of ids (it has 3 items), so the loop ends.
  25. Line 20. print("fixed found", found, "missing", missing) prints nothing.
  26. Line 20. print("fixed found", found, "missing", missing) prints fixed 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:

Interactive · step by step Two ids in one path, from two tenants Step through GET /orgs/1/projects/2 from Ravi, a member of org 1, when project 2 belongs to org 3.
Two ids in one path, from two tenants, step 6 of 6. Showing GET /orgs/1/projects/2, Org 1, Org 3, Project 1: Website, Project 2: Payroll (org 3, not 1: 404). In focus: Project 1: Website, Project 2: Payroll.member?load by idGET /orgs/1/projects/2from RaviOrg 1Ravi is a memberOrg 3Kiran's companyProject 1: Websitebelongs to org 1Project 2: Payrollorg 3, not 1: 404

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. The parent is checked. The handler checks the first id. Ravi is a member of org 1, so the check passes.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

A nested route whose two ids belong to two tenants Python · nested/nested_resources.py
# 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

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

  1. Question 1 of 2 In bulk_ids.py, which ids does the weak version ever compare with the caller when Ravi asks for 2, 5 and 9?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only 2, the first

    A bulk route must check every id it returns. The weak version returned Asha's note 5 because the one comparison it made was about Ravi's own note 2.

  2. Question 2 of 2 In GET /orgs/:org/projects/:id, the handler checks that the caller is a member of :org. What else must it check?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: That the project belongs to :org, the organisation in the path

    Each id in a nested path comes from the request, and so does the claim that one belongs to the other. Load the child by both ids, WHERE id = ? AND org_id = ?, or compare them after loading.

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

  1. One question Ravi asks for note 5, which is Asha's, and for note 9, which does not exist. What should the two answers have in common?

    Choose one answer.

    Show the answer: One question

    Answer: Everything a client can see, from the status code and the body to, as far as possible, the time taken

    A scoped query cannot tell "not yours" from "not there", which is exactly right: the answer should not tell them apart either.

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:

Every route that takes a note id, tried by a second user JavaScript · two_users/two_user_tests.mjs
// 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

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

  1. One question In two_user_tests.mjs, the single read passed against the weak app while three routes failed. What does that say about testing by hand?

    Choose one answer.

    Show the answer: One question

    Answer: The route everyone tries by hand was the one that worked; the later routes needed a test generated from the route table

    Generate the second user's attempts from every route that takes an id, so that the routes written later are tried as carefully as the first one.

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 example 5: { owner: 'asha', text: 'Medical report' };
  • projects: id → { org, name }, and members: 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):

  1. getNote (get_note): the note, or null (None).
  2. updateNote (update_note): set the note's text and return true (True); for any other note change nothing and return false (False).
  3. deleteNote (delete_note): remove the note and return true; otherwise remove nothing and return false.
  4. getNotes (get_notes): { found, missing }, two lists of ids in the order asked: the caller's notes in found, every other id, not theirs or not there, in missing.
  5. getProject (get_project): the project, or null, 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.

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.

  1. Question 1 of 12 What is an insecure direct object reference?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: A request names a record by an id the user controls, and the server uses the record without checking that the caller may

    An id is how a request says which record it wants. The server must still decide whether this caller may use that record.

  2. Question 2 of 12 A download route serves GET /download?file=invoice-1042.pdf from a folder of every customer's invoices, after checking only that the caller is signed in. Is this an IDOR?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Yes; the file name is the object reference, and nothing checks that invoice 1042 is the caller's

    Map the file to its owner and check it, or store files under ids the server looks up through the caller. The path traversal lesson adds the other risk of user-supplied file names.

  3. Question 3 of 12 Which query lets the app return only the caller's order, whatever id the request carries?

    Read the code, then choose one answer.

    -- A
    SELECT * FROM orders WHERE id = ?;
    -- B
    SELECT * FROM orders WHERE id = ? AND customer_id = ?;
    -- C
    SELECT * FROM orders WHERE id = ? OR customer_id = ?;
    Show the answer: Question 3 of 12

    Answer: B, with the order id from the URL and the customer id from the session

    The owner in the condition must come from the session, which the server issued, not from the request, which the caller writes.

  4. Question 4 of 12 Support agents may read the notes of the customers assigned to them, and customers only their own. Which pattern fits?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Load the note, then pass it and the caller to one shared function that every route calls

    Scoped queries are the first choice for ownership. When the rule is richer, a single can(user, action, note) function, called by every route, keeps it in one place to read and test.

  5. Question 5 of 12 Through which of these can a record's random id reach someone who should not open the record? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 5 of 12

    Answer:

    • Server and proxy logs of request paths
    • A list or export that returns ids of records the caller may not open
    • A shared link forwarded in a chat

    Ids leak in ordinary use. That is why an unguessable id is defence in depth, and the check on every route is the control.

  6. Question 6 of 12 Product wants an "anyone with this link can view" button for notes. What should the link's token come with? Choose every one.
    Show the answer: Question 6 of 12

    Answer:

    • A narrow scope, such as view but not edit
    • An expiry
    • A way for the owner to revoke it

    A share link is the one case where an unguessable token is the permission on purpose, so it needs what every permission needs: a scope, a lifetime and an off switch.

  7. Question 7 of 12 The notes API checks the owner in GET /notes/:id. Which of these also need the check? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 7 of 12

    Answer:

    • GET /notes?ids=2,5,9
    • A nightly job that exports a note named by an id in a queue
    • PUT and DELETE /notes/:id

    Every route, job or field that reaches a record through an id from outside needs the same check, so put it in the function that loads the record.

  8. Question 8 of 12 A GraphQL schema reaches an invoice as me { invoices } and as order(id) { invoice }. Only the first resolver checks the owner. What happens?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Any caller who can name an order reaches its invoice through the second path

    Check the owner where the invoice is loaded, in one function both resolvers call, so that every path to the object passes the same check.

  9. Question 9 of 12 Why does the fixed notes API answer 404 for another user's note instead of 403?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: So that a refusal looks exactly like a missing note, and the caller learns nothing about which ids exist

    When a record's existence is private, "not yours" and "not there" share one answer. A scoped query produces it naturally, because it cannot tell them apart.

  10. Question 10 of 12 In ten minutes one account asks for 60 note ids and gets 404 for 58 of them. What should the system do with that?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Log each refusal and alert on the pattern, because repeated failures from one account are rarely an accident

    The same answer keeps ids private; logging and alerting turn a quiet search through ids into a signal someone can act on.

  11. Question 11 of 12 What does a two-user test for a notes API do?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Creates a note as one user, then tries it as a second user through every route that takes a note id

    The second user is the point: each attempt must answer like a missing record and leave the first user's note unchanged.

  12. Question 12 of 12 A suite tests that Asha can read, edit and delete her own notes, and every test passes on the weak app. Why?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: The weak app does serve owners correctly; only a second user's attempts would fail

    A test that cannot fail on the weak code proves nothing about the fix. Run the second user's attempts against the weak version first and watch them fail.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.