IDOR Explained: Insecure Direct Object Reference Attacks and Prevention
An insecure direct object reference (IDOR) lets an authenticated user read, modify, or delete another user's data by changing an object identifier in the request. It is CWE-639 (Authorization Bypass Through User-Controlled Key), the top category in the OWASP Top 10 since 2021 (A01 Broken Access Control), and the #1 API risk as API1:2023 Broken Object Level Authorization (BOLA). This guide covers the mechanics, a reproducible local lab, detection, and ownership-check fix patterns.
What IDOR is and why it keeps mattering
IDOR is an authorization failure, not an input-validation failure. The application authenticates the user, then trusts a client-supplied object reference (a database key, a filename, an invoice number) to decide which record to load, without ever checking whether the requester is allowed to touch that record. Authentication answers "who are you?"; the missing check is "what may you touch?". That gap is CWE-639, which MITRE describes as a system whose authorization functionality "does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data."
The taxonomy has shifted over the years, which confuses reporting. IDOR was a standalone entry (A4) in the OWASP Top 10 from 2007 to 2013, then was folded into Broken Access Control, which became A01:2021 with 318,487 recorded occurrences and 19,013 mapped CVEs across 34 CWEs, the most occurrences in the contributed dataset. The 2025 edition keeps Broken Access Control at #1: 100% of the applications tested were found to have some form of it, with the highest number of occurrences in the contributed data. In the API world the same flaw is called Broken Object Level Authorization (BOLA) and has held the #1 spot in the OWASP API Security Top 10 since 2019, rated Easy exploitability, Widespread prevalence, and Easy detectability in the 2023 edition.
| Framework | Entry | Name |
|---|---|---|
| OWASP Top 10:2021 / 2025 | A01 | Broken Access Control: "permitting viewing or editing someone else's account, by providing its unique identifier (insecure direct object references)" |
| OWASP API Security Top 10:2023 | API1 | Broken Object Level Authorization (BOLA): the API-specific framing of IDOR |
| MITRE CWE | CWE-639 | Authorization Bypass Through User-Controlled Key (in the CWE Top 25); parent CWE-284, child CWE-566 for SQL-key variants |
Two real-world data points show the blast radius:
| Incident | IDOR role | Outcome |
|---|---|---|
| First American Financial, 2019 | Document-sharing URLs exposed sequential internal identifiers; changing a number in the URL returned documents belonging to other customers. | More than 800 million documents exposed, including Social Security numbers and bank account details. |
| CVE-2025-41096, BOLD Workplanner | "Insecure Direct Object Reference (IDOR) vulnerability ... consisting of a lack of adequate validation of user input, allowing an authenticated user to access ... contract details using unauthorised internal identifiers." | CWE-639, CVSS-B 7.1 (HIGH) as assessed by INCIBE; fixed in version 2.5.25. Proof that plain IDOR findings still ship in commercial products. |
In MITRE ATT&CK terms, IDOR exploitation is the mechanism behind T1213 (Data from Information Repositories) and, when credentials or tokens are exposed through it, feeds T1078 (Valid Accounts). The horizontal variant (one user reading a peer's data) is the most common; the vertical variant (reaching an admin-controlled object) escalates into privilege escalation.
Attack anatomy: the request is valid, the authorization is missing
The OWASP Top 10's canonical example is the account lookup: the application uses an unverified parameter directly in a query, and the attacker simply changes the value.
1. Attacker
Logs in normally as their own user
2. Attacker
Changes the object ID in a valid request
3. Application
Authenticates the session, skips the ownership check
4. Application
Returns or modifies another user's object
GET /app/accountInfo?acct=notmyacct HTTP/1.1 Cookie: session=<alice's session>
The request is fully legitimate: authenticated, well-formed, and routed to the right endpoint. Stronger authentication (even phishing-resistant passkeys) does not help: it proves who the caller is, not which objects they may touch. The flaw is that the endpoint resolves whatever object the acct parameter names instead of an object the caller is allowed to see. The same primitive appears in every place an object reference crosses the trust boundary:
| Attack surface | Example | Reference type |
|---|---|---|
| URL path parameter | /api/invoices/1042 changed to /api/invoices/1043 | Sequential database key |
| Query string parameter | ?order_id=7001 changed to ?order_id=7002 | Sequential database key |
| Static file reference | /static/12144.txt: chat transcripts stored with incrementing filenames | Filename |
| POST body / hidden field | user_id in a form submission changed to another user's ID | Primary key or UUID |
OWASP's API Top 10 is explicit that the identifier's data type does not matter: "Object IDs can be anything from sequential integers, UUIDs, or generic strings. Regardless of the data type, they are easy to identify in the request target (path or query string parameters), request headers, or even as part of the request payload." Once one reference is confirmed controllable, exploitation scales by enumeration: a script iterating candidate IDs turns a single missing check into a mass exfiltration, which is exactly what happened at First American.
IDOR is not the same as Broken Function Level Authorization (BFLA, API5:2023). In BOLA the user is allowed to call the endpoint; the violation is at the object level. In BFLA the user calls a function (an admin endpoint) they should not be able to reach at all.
Reproducible local lab
Everything below runs against a single local container on your machine, with no live targets, no weaponized payloads. The lab is a Flask document API with two users (alice, bob) and four documents, deliberately missing the ownership check in the read and delete handlers.
docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"Dockerfile
FROM python:3.12-slim WORKDIR /app RUN pip install --no-cache-dir flask COPY app.py . EXPOSE 8080 CMD ["python", "app.py"]
app.py: document API with missing object-level authorization
from functools import wraps
from flask import Flask, jsonify, request, session
app = Flask(__name__)
app.secret_key = "lab-secret-key"
# Deterministic seed data. owner is the username.
USERS = {
"alice": {"password": "alice-pass"},
"bob": {"password": "bob-pass"},
}
DOCS = [
{"id": 1, "owner": "alice", "title": "alice-vacation-plan", "body": "alice-private-1"},
{"id": 2, "owner": "alice", "title": "alice-tax-notes", "body": "alice-private-2"},
{"id": 3, "owner": "bob", "title": "bob-bank-export", "body": "bob-private-3"},
{"id": 4, "owner": "bob", "title": "bob-contract-draft", "body": "bob-private-4"},
]
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
if "username" not in session:
return jsonify({"error": "authentication required"}), 401
return f(*args, **kwargs)
return wrapper
@app.post("/login")
def login():
data = request.get_json(force=True)
user = USERS.get(data.get("username", ""))
if not user or user["password"] != data.get("password"):
return jsonify({"error": "bad credentials"}), 401
session["username"] = data["username"]
return jsonify({"logged_in_as": data["username"]})
@app.get("/api/documents")
@login_required
def list_documents():
mine = [d for d in DOCS if d["owner"] == session["username"]]
return jsonify(mine)
# --- VULNERABLE: no ownership check (CWE-639) -------------------------------
@app.get("/api/documents/<int:doc_id>")
@login_required
def get_document(doc_id):
doc = next((d for d in DOCS if d["id"] == doc_id), None)
if doc is None:
return jsonify({"error": "not found"}), 404
return jsonify(doc)
@app.delete("/api/documents/<int:doc_id>")
@login_required
def delete_document(doc_id):
for i, d in enumerate(DOCS):
if d["id"] == doc_id:
DOCS.pop(i)
return jsonify({"deleted": doc_id})
return jsonify({"error": "not found"}), 404
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)Run it and exploit it
docker compose up --build
Log in as alice, then ask for document 3, which belongs to bob:
$ curl -s -c jar.txt -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-pass"}' http://localhost:8080/login
{"logged_in_as":"alice"}
$ curl -s -b jar.txt http://localhost:8080/api/documents/3
{"body":"bob-private-3","id":3,"owner":"bob","title":"bob-bank-export"}Alice just read Bob's bank export. The read variant is the classic information-disclosure IDOR. The write variant is worse: the same missing check lets alice delete Bob's documents:
$ curl -s -b jar.txt -X DELETE http://localhost:8080/api/documents/3
{"deleted":3}And because the IDs are sequential, a three-line loop converts the single finding into full enumeration, the First American pattern in miniature:
$ for i in 1 2 3 4; do curl -s -b jar.txt http://localhost:8080/api/documents/$i; echo; done
{"body":"alice-private-1","id":1,"owner":"alice","title":"alice-vacation-plan"}
{"body":"alice-private-2","id":2,"owner":"alice","title":"alice-tax-notes"}
{"body":"bob-private-3","id":3,"owner":"bob","title":"bob-bank-export"}
{"body":"bob-private-4","id":4,"owner":"bob","title":"bob-contract-draft"}The fix: scope the query to the authenticated user
The same two handlers, fixed by adding the session owner to the lookup predicate. This is the "unscoped query vs scoped query" pattern from the OWASP reference material (Document.find(id) becomes current_user.documents.find(id)):
def owned_doc(doc_id):
"""Return the doc only if it belongs to the session user.
Missing and not-owned both return None, so callers respond 404 either
way — no existence oracle for the enumerator.
"""
return next(
(d for d in DOCS if d["id"] == doc_id and d["owner"] == session["username"]),
None,
)
@app.get("/api/documents/<int:doc_id>")
@login_required
def get_document(doc_id):
doc = owned_doc(doc_id)
if doc is None:
return jsonify({"error": "not found"}), 404
return jsonify(doc)
@app.delete("/api/documents/<int:doc_id>")
@login_required
def delete_document(doc_id):
doc = owned_doc(doc_id)
if doc is None:
return jsonify({"error": "not found"}), 404
DOCS.remove(doc)
return jsonify({"deleted": doc_id})Re-running the walkthrough now returns 404 for document 3 while alice is logged in, the same status code a missing document returns, so the attacker cannot even distinguish "exists but not yours" from "does not exist":
$ curl -s -b jar.txt http://localhost:8080/api/documents/3
{"error":"not found"}What does not work (and why that teaches you more)
- Comparing the session user id with the ID parameter. OWASP's API Top 10 is blunt: "Comparing the user ID of the current session (e.g. by extracting it from the JWT token) with the vulnerable ID parameter isn't a sufficient solution to solve Broken Object Level Authorization." The object's owner is the thing to check, not whether the client says the IDs match. A check comparing two client-influenced values proves nothing, and a verifier vulnerable to JWT algorithm confusion hands the attacker both.
- Switching to UUIDs. OWASP recommends random GUIDs for record IDs, but as a mitigation, not a control: an unpredictable identifier raises the enumeration bar, yet the reference still travels in every request and leaks through shared links, caches, and logs. Possession of the ID must never be treated as authorization. The lab's vulnerability survives a UUID swap unchanged. Only the loop's range changes.
- Hiding the check client-side. ASVS 4.0.3 V4.1.1 requires access-control rules to be enforced "on a trusted service layer": JavaScript can hide an admin button, but the API behind it is one curl away.
- Rate limiting alone. OWASP lists rate limits as a way to "minimize the harm from automated attack tooling". They slow enumeration but do not fix the missing check. Rate limiting an unauthenticated-by-design data endpoint just caps the bleed rate.
Detecting IDOR
Unlike SQL injection or SSRF, IDOR has no network signature: every request is individually valid and well-formed; the failure is in the authorization decision behind it. Detection is therefore testing-first (dynamic + static), with log analytics as the runtime backstop.
Dynamic: the two-account replay (Autorize-style)
The reference method (PortSwigger's authorization-testing methodology) needs two accounts. Register users A and B. Configure an interception tool (Burp Autorize, AuthMatrix, or a simple proxy script) to replay every request made as A with B's session cookie; any response that is not the baseline denial (401/403) is a candidate. Then do the reverse direction, and then the manual variant: as A, request B's objects directly by swapping the identifier in the path, query string, body, and headers. Test every verb (GET, PUT, PATCH, DELETE), because the read check and the write check are often implemented separately (the lab above shows a read-only tester would miss the destructive delete).
Static: a Semgrep taint rule as a starting point
SAST catches the mechanical variant: request-derived values flowing into primary-key lookups with no ownership filter. This is a template. Adjust the model and route conventions to your codebase, and treat its findings as review candidates, not proof:
# semgrep --config bola.yml (template — adapt to your ORM/routes)
rules:
- id: flask-bola-unscoped-lookup
mode: taint
pattern-sources:
- pattern: request.args
- pattern: request.view_args
pattern-sinks:
- pattern: $MODEL.query.get($ARG)
- pattern: $MODEL.query.get_or_404($ARG)
- pattern: $MODEL.query.first()
message: >-
Object lookup keyed on request input without an ownership filter —
likely BOLA/IDOR (CWE-639). Scope the query to the current user.
languages: [python]
severity: WARNINGRuntime: enumeration analytics
OWASP's A01 prevention guidance includes "log access control failures, alert admins when appropriate (e.g., repeated failures)". In practice, look for the enumeration pattern in access logs: a single session issuing many distinct object IDs in a short window, especially with a mix of 200 and 404 responses on the same endpoint, or a sudden rise in 403/404 volume per user. This catches the post-exploitation sweep; it cannot catch the single-ID probe.
How to prevent IDOR
Prevention is an architecture rule, not a per-endpoint scramble: object-level authorization on the trusted service layer, scoped queries everywhere, and tests that prove cross-user access fails. The OWASP API Top 10's prevention list is short and exact: implement a proper authorization mechanism that relies on user policies and hierarchy; check, in every function that uses client input to access a record, that the logged-in user may perform the requested action on that record; prefer random unpredictable GUIDs; and write tests that fail on any change breaking the authorization mechanism.
| Layer | Control | Reference |
|---|---|---|
| Design | Deny by default; authorization on a trusted service layer, never client-side | OWASP A01:2021/2025; ASVS 4.0.3 V4.1.1 |
| Data | Attributes used by access controls cannot be manipulated by end users | ASVS 4.0.3 V4.1.2 (CWE-639) |
| Query layer | Scope every lookup to the current user (ownership predicate in the query, not a post-fetch comparison) | OWASP IDOR reference; this lab |
| Response | Identical response for missing and not-owned objects (no existence oracle) | Common testing methodology |
| Identifiers | Random unpredictable IDs (GUIDs) as defense-in-depth, never as the control | OWASP API1:2023 |
| CI | Functional authorization tests (two-account, all verbs) that fail the build | OWASP API1:2023; OWASP A01:2021 |
| Runtime | Log access-control failures; alert on enumeration patterns; rate limits to slow automated tooling | OWASP A01:2021 |
Node.js (Express + MongoDB): scoped findOne
// insecure: unscoped lookup
const doc = await db.collection('documents').findOne({
_id: ObjectId(req.params.id),
});
// fixed: ownership predicate in the query, same 404 for both outcomes
const doc = await db.collection('documents').findOne({
_id: ObjectId(req.params.id),
ownerId: req.user.id, // from the verified session, never from the request
});
if (!doc) return res.status(404).json({ error: 'not found' });Go (GORM-style): where clause with the caller's id
// insecure
var doc Document
db.First(&doc, "id = ?", c.Param("id"))
// fixed: scope to the authenticated user (currentUser from the session)
var doc Document
err := db.Where("id = ? AND owner_id = ?", c.Param("id"), currentUser.ID).First(&doc).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}A test that would have caught the lab bug
def test_cross_user_read_is_denied(client):
client.post("/login", json={"username": "alice", "password": "alice-pass"})
# document 3 belongs to bob — alice must never receive it
resp = client.get("/api/documents/3")
assert resp.status_code == 404
def test_cross_user_delete_is_denied(client):
client.post("/login", json={"username": "alice", "password": "alice-pass"})
resp = client.delete("/api/documents/3")
assert resp.status_code == 404The two tests encode the two most-missed directions: reads (information disclosure) and destructive writes. Run them as a normal authenticated user. A suite that only tests the admin role, or only tests with a user who owns everything, will pass on a broken authorization mechanism.
Prevention checklist
| Check | How to verify |
|---|---|
| Every handler taking an object reference enforces ownership on the trusted service layer | Code review: enumerate handlers with path/query IDs; confirm each scopes to the session principal |
| No unscoped lookups in the codebase | Semgrep taint rule in CI; grep for bare .get(/findOne( calls fed from request data |
| Cross-user read and write denied, same response for missing vs not-owned | Two-account integration test on every authenticated endpoint, all verbs |
| Object references not attacker-influenceable beyond the object id | Review request bodies and headers for owner/role fields the client can set (ASVS 4.1.2) |
| Random unpredictable IDs where feasible, never relied on as the control | Schema review: sequential PKs exposed in URLs/APIs replaced by GUIDs |
| Access-control failures logged, enumeration alerting configured | Trigger a probe in staging; confirm the log line and alert fire |
| Rate limits on authenticated API endpoints | Load-test an endpoint; confirm the 429 threshold |
Key takeaways
- IDOR is missing object-level authorization: the request is valid, authenticated, and well-formed; the object it names is simply not checked against the caller's rights (CWE-639).
- It is the top OWASP category (A01 since 2021, 2025 included) and the #1 API risk (API1:2023 BOLA), and it ships in real products today (CVE-2025-41096).
- The fix is one line per handler (scope the query to the session principal), but only counts when applied to every endpoint, on the trusted service layer, for reads and writes alike.
- UUIDs slow enumeration; they do not authorize. The check is the control.
- IDOR has no network signature: detect it with two-account dynamic testing, SAST taint rules, and enumeration-aware log monitoring.
Frequently asked questions
- What is an IDOR vulnerability?
- An insecure direct object reference (IDOR, CWE-639; BOLA in API terms) is a missing object-level authorization check: the request is authenticated and well-formed, but the endpoint returns or modifies whatever object the ID parameter names without checking that the caller may access it.
- Do UUIDs prevent IDOR?
- No. Random identifiers make enumeration harder, but IDs still leak through shared links, caches and logs, and possession of an ID is not authorization. The vulnerable handler stays vulnerable after a UUID swap.
- How do you fix IDOR?
- Scope every object lookup to the session principal (for example, query by both the object ID and the owner taken from the server-side session) on a trusted service layer, for reads and writes alike. The check only counts where it is actually applied, so apply it on every endpoint.
- How do you test for IDOR?
- Use two accounts: capture a request made as user A, replay it with user B's session, and treat any response other than a denial as a finding. Tools such as Autorize automate the replay; SAST taint rules and enumeration-aware log monitoring cover what testing misses.
Kokkuvõte eesti keeles
IDOR (ingl k Insecure Direct Object Reference) on juurdepääsukontrolli nõrkus, kus rakendus laeb objekti (näiteks dokumendi või arve) kasutaja antud identifikaatori järgi, kontrollimata, kas see objekt kuulub just sellele kasutajale. Nii saab sisselogitud kasutaja teise kasutaja andmeid lugeda, muuta või kustutada: piisab numbri muutmisest URL-is. See on CWE-639, OWASP Top 10 esimene kategooria (A01 Broken Access Control) alates 2021. aastast ja API-de puhul API1:2023 Broken Object Level Authorization (BOLA). Parandus: iga päring, mis kasutab kliendi sisendit objekti otsimiseks, peab otsingu kitsendama sisselogitud kasutajaga (näiteks owner == session.user), ning puuduva ja võõra objekti puhul tuleb vastata ühtemoodi (404). UUID-d muudavad loendamise raskemaks, kuid ei asenda kontrolli. Täielik laborikäik ja koodinäited on ülal inglise keeles.
Sources
- OWASP Top 10:2021: A01 Broken Access Control
- OWASP Top 10:2025: A01 Broken Access Control
- OWASP API Security Top 10:2023: API1 Broken Object Level Authorization
- CWE-639: Authorization Bypass Through User-Controlled Key
- OWASP: Insecure Direct Object Reference (attack description)
- PortSwigger Web Security Academy: Insecure direct object references (IDOR)
- PortSwigger Web Security Academy: Access control (authorization testing methodology)
- OWASP ASVS 4.0.3: V4.1 Access Control (4.1.1, 4.1.2)
- NVD: CVE-2025-41096 (BOLD Workplanner IDOR)
- SentinelOne: What Is Insecure Direct Object Reference (IDOR)? (First American 2019)
- MITRE ATT&CK T1213: Data from Information Repositories