Skip to main content

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.

FrameworkEntryName
OWASP Top 10:2021 / 2025A01Broken Access Control: "permitting viewing or editing someone else's account, by providing its unique identifier (insecure direct object references)"
OWASP API Security Top 10:2023API1Broken Object Level Authorization (BOLA): the API-specific framing of IDOR
MITRE CWECWE-639Authorization 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:

IncidentIDOR roleOutcome
First American Financial, 2019Document-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. 1. Attacker

    Logs in normally as their own user

  2. 2. Attacker

    Changes the object ID in a valid request

  3. 3. Application

    Authenticates the session, skips the ownership check

  4. 4. Application

    Returns or modifies another user's object

IDOR: every step is legitimate except the one that is missing: the ownership check.
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 surfaceExampleReference type
URL path parameter/api/invoices/1042 changed to /api/invoices/1043Sequential database key
Query string parameter?order_id=7001 changed to ?order_id=7002Sequential database key
Static file reference/static/12144.txt: chat transcripts stored with incrementing filenamesFilename
POST body / hidden fielduser_id in a form submission changed to another user's IDPrimary 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: WARNING

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

LayerControlReference
DesignDeny by default; authorization on a trusted service layer, never client-sideOWASP A01:2021/2025; ASVS 4.0.3 V4.1.1
DataAttributes used by access controls cannot be manipulated by end usersASVS 4.0.3 V4.1.2 (CWE-639)
Query layerScope every lookup to the current user (ownership predicate in the query, not a post-fetch comparison)OWASP IDOR reference; this lab
ResponseIdentical response for missing and not-owned objects (no existence oracle)Common testing methodology
IdentifiersRandom unpredictable IDs (GUIDs) as defense-in-depth, never as the controlOWASP API1:2023
CIFunctional authorization tests (two-account, all verbs) that fail the buildOWASP API1:2023; OWASP A01:2021
RuntimeLog access-control failures; alert on enumeration patterns; rate limits to slow automated toolingOWASP 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 == 404

The 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

CheckHow to verify
Every handler taking an object reference enforces ownership on the trusted service layerCode review: enumerate handlers with path/query IDs; confirm each scopes to the session principal
No unscoped lookups in the codebaseSemgrep 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-ownedTwo-account integration test on every authenticated endpoint, all verbs
Object references not attacker-influenceable beyond the object idReview 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 controlSchema review: sequential PKs exposed in URLs/APIs replaced by GUIDs
Access-control failures logged, enumeration alerting configuredTrigger a probe in staging; confirm the log line and alert fire
Rate limits on authenticated API endpointsLoad-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

Related Guides

Business Logic & Concurrency

All security guides by attack family