An endpoint takes an object identifier (an invoice number, a user id, a file name) and returns or changes that object without checking that the caller may access it. Changing the identifier in the URL or the request body is enough to reach another customer's data.
At a glance
- Why it matters
- Large-scale data exposure with minimal effort: an attacker iterates identifiers and downloads every record the endpoint serves. When the endpoint also writes, the attacker can change other users' settings, orders or payouts. It is one of the most common findings in APIs and mobile back ends.
- How to fix it
- Authorise every object lookup on the server against the authenticated user, in one shared layer rather than per handler. Scope queries by owner (WHERE owner_id = current user) instead of filtering afterwards. Random identifiers make enumeration harder but are not a control on their own.
Description
IDOR is an authorisation failure, not an authentication one: the application correctly confirms who the caller is, but does not check whether that caller is allowed to access the specific object being requested. It typically appears as a predictable or sequential identifier in a URL or API call (/invoices/1042, ?userId=88) where changing the identifier to another value the caller does not own still returns data, because the only check performed was that the caller was logged in, not that the caller owns object 1042.
Impact
IDOR is one of the most common ways real data breaches happen, because it is quiet: no error, no crash, just data belonging to someone else returned as if it were the caller's own. Depending on the endpoint, this ranges from reading another customer's invoice or personal details to modifying or deleting another user's records, and it scales linearly with how many identifiers an attacker is willing to try, which for a sequential integer ID is trivial to automate.
How we find it
- We test every endpoint that accepts an identifier (in the URL path, a query parameter, or the request body) as two different authenticated users, checking whether user A's session can access user B's objects by substituting the identifier.
- We enumerate sequential or otherwise guessable IDs against a live session to see how far the pattern extends, and we test both read (GET) and write (PUT/PATCH/DELETE) operations, since a common failure pattern authorises reads but forgets to authorise writes, or the reverse.
- We test IDs referenced indirectly too, such as an attachment ID embedded in a document a user does own, and export or bulk endpoints, which sometimes skip the per-object check that the single-object endpoint enforces correctly.
Fix
Check ownership or explicit authorisation for every object access, on every request, server-side, using the authenticated caller's identity, never trusting an identifier's mere presence in a request as proof of entitlement to it. Prefer indirect references (a per-user token or UUID mapped server-side) over exposing raw sequential database IDs, and enforce the same ownership check uniformly across read, write and bulk/export code paths.
# Vulnerable: fetches by ID with no ownership check
@app.route("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
invoice = db.get_invoice(invoice_id)
return jsonify(invoice)
# Fixed: the query itself is scoped to the authenticated caller
@app.route("/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = db.get_invoice(invoice_id, owner_id=current_user.id)
if invoice is None:
abort(404) # not found, not "forbidden": avoid confirming existence
return jsonify(invoice)
References
- OWASP Top 10: A01:2021, Broken Access Control
- CWE-639: Authorization Bypass Through User-Controlled Key
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
Practise finding and fixing it in the find-and-fix lab Fix the Profile API: Missing Ownership Check.