IDOR and Access Control Flaws
On this page
The Endpoint That Trusts the Number#
IDOR (Insecure Direct Object Reference) is the easiest authorization flaw to explain and among the most common to find in real products. The application accesses an object by an identifier the user supplies, and never checks whether this user is allowed to touch that object. GET /order/1942 returns order 1942 to anyone who asks, including a logged-in user who is not its owner. It is a specific case of Broken Access Control, the number-one category in the OWASP Top 10, and it is everywhere because the happy path works perfectly for the legitimate owner while the missing check is invisible until someone changes the number.
Authentication Is Not Authorization#
The root cause is confusing two questions. Authentication asks "is someone logged in?" Authorization asks "may this specific user perform this action on this specific object?" IDOR code answers the first and skips the second:
# BROKEN: checks that a user is logged in, not that the order is theirs
@app.route("/order/<int:order_id>")
@login_required
def get_order(order_id):
return jsonify(db.orders.find_one({"_id": order_id}))
@login_required ensures a valid session, then the handler fetches whatever id was requested. Any authenticated user can enumerate 1, 2, 3, ... and read every order in the system.
Horizontal and Vertical#
Two flavours are worth naming because they show up differently in testing:
- Horizontal IDOR: accessing a peer's object at the same privilege level, another user's account, documents, messages, or orders, by changing the identifier.
- Vertical IDOR (Broken Function Level Authorization): reaching a higher-privilege object or endpoint your role was never granted, such as a normal user calling
DELETE /admin/users/5even though the UI never shows that button.
Both share the same fix: an unconditional authorization check on every object access. A third pattern, sometimes called a write IDOR, is when the missing check is on a modifying action rather than a read, POST /order/1942/refund issuing a refund on someone else's order. Write IDOR is more damaging than read IDOR because it changes state, yet it is often missed because testers focus on data they can see rather than actions they can trigger.
Obscure IDs Are Not a Fix#
A frequent reflex is "switch from sequential integers to random UUIDs so attackers can't guess them." That is real hardening, and worth doing to prevent trivial enumeration, but it is not a fix. Obscurity fails the moment an id leaks, and ids leak constantly: in shared links, Referer headers, emails, exported files, public listings, browser history, and client-side code. An unguessable id that appears in any of these becomes a known id, and the missing authorization check is still missing. Never rely on the difficulty of guessing an identifier as your access control.
The Correct Control: Scope Every Access to the User#
The reliable fix is to make cross-user access impossible, not merely hard. Look the object up scoped to the current user, or verify ownership before returning it:
# SECURE: the query itself is constrained to the caller
@app.route("/order/<int:order_id>")
@login_required
def get_order(order_id):
order = db.orders.find_one({"_id": order_id, "owner_id": current_user.id})
if order is None:
abort(404) # not found OR not yours: same response
return jsonify(order)
A scoped query ("find my order with this id") silently returns nothing for an id the user does not own, so it cannot be wrong. Verifying ownership after a fetch is equally valid as long as the check is unconditional and runs before any data is returned or any action is taken.
One detail matters: return the same response for "does not exist" and "exists but not yours," typically 404. If a non-owned object returns 403 while a truly missing one returns 404, the difference is an oracle that lets an attacker enumerate which ids exist even though they cannot read them.
APIs Make It Worse#
REST and GraphQL APIs hand out object references by design, so IDOR gets its own name in the API world: BOLA (Broken Object Level Authorization), the top entry in the OWASP API Security Top 10. Several API patterns amplify it:
- Batch parameters:
GET /api/items?ids=1,2,3becomes a bulk data leak if any non-owned id is honoured. - GraphQL: a single query can request many objects by id; the ownership check must apply to each resolved node, not just the top-level query.
- Mass assignment: a request body containing
owner_idorrolecan let a normal call reassign an object or escalate privilege if the server binds unlisted fields. Accept an explicit allowlist of fields per action.
Treat every id in a request as a claim that requires an ownership check, never as a credential in itself.
Where the Identifier Hides#
IDOR is not only about numbers in the URL path. The user-controlled reference can appear in many places, and each is a candidate:
- Query parameters:
?account=1942,?file=report_88.pdf. - Request bodies: a JSON field like
{"projectId": 12}on a POST or PATCH. - Cookies and headers: an id stored client-side and trusted on the way back.
- Filenames and storage keys: a download endpoint that maps a supplied name straight to a stored file (overlapping with path traversal).
- Indirect references: an id inside an exported document, a webhook payload, or a hidden form field.
A thorough review lists every place the client can name an object and confirms each one runs the ownership check. It is common for the primary GET /order/{id} to be protected while a forgotten GET /order/{id}/invoice.pdf or POST /order/{id}/cancel is not, because the check was added per-handler instead of centrally.
Finding and Fixing at Scale#
Test systematically rather than hoping:
- For each object endpoint, log in as user A, capture a request for A's object, then replay it with B's session (or swap the id to B's object) and confirm you get
403/404, not the data. Burp Suite's "Autorize" or "AuthMatrix" extensions automate this across a whole site. - Try a low-privilege account against admin endpoints to catch the vertical variant.
- Confirm existence is not leaked (consistent status codes and timing).
The durable fix is architectural: define authorization in one place, a middleware, a policy layer, or a query scope that every handler goes through, rather than sprinkling ad-hoc if user.id == ... checks in each controller where one will eventually be forgotten. Centralised, deny-by-default authorization is the single control that turns a quiet data leak into a non-event. Then lock it in with tests: run the test suite as several roles against every route and assert the denials, so a future refactor that drops a check fails CI instead of shipping.
Checklist#
- Every object access is scoped to, or checked against, the authenticated user.
- Missing and forbidden objects return the same response (no existence oracle).
- Identifiers are treated as untrusted claims, not as access grants; UUIDs are hardening, not the control.
- Request bodies bind only allowlisted fields (no mass assignment of
owner_id/role). - Authorization is centralised in one layer and covered by multi-role tests in CI.
- Batch and GraphQL endpoints apply the ownership check to every returned object.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.