Biterra Labs preview / Intro to Web Exploitation

IDOR and Access Control

IDOR stands for insecure direct object reference. The app takes an identifier from the request — a user ID, order ID, filename — and fetches that object without checking whether the current user is allowed to see it. You change the ID and receive someone else's data.

How it happens

A profile page is https://example.com/profile?id=123. The server does:

flowchart LR A["Request: profile?id=123"] --> B["Fetch user 123 from the database"] B --> C["Return user 123's profile"] D["Missing: check whether this user may view profile 123"] -.-> B

It never asks "is the logged-in user allowed to see 123?" Change the URL to ?id=124 and you might see another profile.

The same idea shows up as:

The reference comes directly from the request. The app is insecure because it authenticates you, then forgets to authorise the object.

Do not confuse this with path traversal. If ?file=../admin/flag.txt works, that is a file-path bug. IDOR is asking for a real object by its normal identifier.

Access-control shapes

TypeExample
HorizontalOne customer reads or changes another customer's order
VerticalA normal user accesses an object or action reserved for administrators
Context-dependentA support agent can open tickets assigned to them, but not every ticket

IDOR most often causes horizontal access-control failures, but the same missing object check can cross roles or depend on the application's rules.

IDs are references, not permissions

The object reference is not always a number in the URL:

ReferenceExample
Number/orders/457
Filename/invoices/account-b-july.pdf
UUID/documents/0d50b4d2-70a7-4e2d-9138-f39c4d20c828
JSON field{"user_id":124}

Sequential numbers make valid objects easier to guess, but replacing them with UUIDs does not fix missing authorisation. A UUID can still leak through a link, log, email, or API response. Unpredictable identifiers help, but they are not permission checks.

Worked example: two users, two orders

Imagine a shop with two accounts. Each account owns one order:

flowchart LR A["Account A"] -->|owns| OA["Order 456"] B["Account B"] -->|owns| OB["Order 457"] A -.->|requests with account A's session| OB OB --> C{"Does the server check ownership?"} C -->|No| V["200 OK: account B's order is exposed"] C -->|Yes| S["403 or 404: access refused"]

An ordinary request from account A asks for its own order:

HTTP
GET /api/orders/456 HTTP/1.1
Cookie: session=account-a
HTTP
HTTP/1.1 200 OK
Content-Type: application/json

{"id":456,"owner":"account-a","item":"Keyboard"}

Now suppose the same account A session requests account B's known order. Only the object reference is different:

HTTP
GET /api/orders/457 HTTP/1.1
Cookie: session=account-a

A vulnerable response exposes B's object:

HTTP
HTTP/1.1 200 OK
Content-Type: application/json

{"id":457,"owner":"account-b","item":"Monitor"}

A secure application refuses the request. It may return 403 Forbidden, or deliberately use 404 Not Found so it does not reveal whether the object exists.

This is why a 404 alone is inconclusive: the object may not exist, or the application may be hiding an object account A cannot access.

The same ownership rule should apply when an object is edited, deleted, downloaded, or exported. A safe read route does not prove the other operations are safe.

IDOR is not only about reading

The same missing check may let one user change or delete another user's object. Suppose order 457 belongs to account B, but account A sends:

HTTP
PATCH /api/orders/457 HTTP/1.1
Cookie: session=account-a
Content-Type: application/json

{"delivery_address":"Account A's address"}

If the update succeeds, the server checked that account A was logged in but failed to check whether account A could modify order 457.

OperationPossible impact
GET /orders/457Read someone else's order
PATCH /orders/457Change someone else's order
DELETE /orders/457Delete someone else's order
GET /orders/457/invoiceDownload a related protected file

A reference can also sit inside a nested route such as /teams/8/documents/42. A check for access to team 8 does not automatically prove the user may access every document inside it.

How to prevent it

The vulnerable pattern fetches from every order in the database using only the client-supplied ID:

Python
order = Order.query.get_or_404(order_id)
return jsonify(order.to_dict())
JavaScript
const order = await prisma.order.findUnique({
  where: { id: Number(request.params.id) },
});
response.json(order);

Instead, scope the lookup to objects the current user may access:

Python
order = Order.query.filter_by(
    id=order_id,
    user_id=current_user.id,
).first_or_404()
return jsonify(order.to_dict())
JavaScript
const order = await prisma.order.findFirst({
  where: {
    id: Number(request.params.id),
    userId: request.user.id,
  },
});
if (!order) return response.sendStatus(404);
response.json(order);

The identifier still chooses an order, but only from the current user's authorised set. More complex applications may use roles, team membership, ownership, or a central policy check instead. Apply the check on the server for every operation; hiding a link or button in the browser is not access control.

Common mistakes

Tools and resources

Only practise against systems you own or are explicitly authorised to test, such as this lab.

Self-check

An order API replaces sequential IDs with UUIDs. While signed in as account A, a request using account B's known order UUID still returns B's order. Has the UUID fixed the vulnerability?

No. The UUID made the reference harder to guess, but the server still failed to check whether account A could access that order.

Next

Planet Express Dispatch

Before you start

Look for object identifiers in the URL, body, or request parameters.

  • keep the same session
  • change one identifier cleanly
  • compare the new response with the baseline

If the app still trusts you, you may be testing access control rather than business logic.

Open Planet Express Dispatch