07.LAB · Hands-On

IDOR Scavenger Hunt

Six endpoints. Each one has a flaw. Modify a parameter; access something you shouldn't.

Insecure Direct Object Reference (IDOR) is a class of access-control bug that shows up everywhere: when a server uses an ID from the request to look up a resource but never checks whether the requesting user is authorized to see it. The classic example is GET /invoice/47 — you got your own invoice 47; the next user's invoice 48 is one URL change away.

You are logged in to Heliotrope Defense Systems as alice (user_id=1001). The six endpoints below are real-looking REST API endpoints. Each has an IDOR vulnerability of a different shape. Edit the parameter, hit Send, and see what comes back. When you successfully exploit one, the "Show fix" button reveals the correct authorization pattern.

Session: alice@heliotrope.com · user_id=1001 · role=employee · Authorization: Bearer eyJhbGciOi...
6
Endpoints
0
Solved
6
Remaining
1
Direct numeric ID · view another user's invoice
VULNERABLE

Your invoice: https://app.example.com/api/invoice/1001 returns your own data. The URL takes an integer. What happens if you change it?

GET
No request sent yet.
Try /api/invoice/1002, /1003, etc.
The fixCheck ownership on every request. Pseudocode: invoice = db.invoices.find(id); if (invoice.user_id != session.user_id) return 403;. Use the session, not a parameter, to identify the user. Where ownership is more complex (shared resources, organizations), apply a role-based check. Never trust the ID alone.
2
UUIDs · guessing the ID doesn't work
VULNERABLE

The team's docs endpoint: /api/docs/{uuid}. Your doc UUID is 3f8a7b9c-2d4e-4a1f-8b6c-1e9d0a2c3f4b. UUIDs are unguessable, right?

... but the developer also added /api/docs?owner_id=1001 as a "convenience" filter.

GET
No request sent yet.
Try changing owner_id=1001 to a different number, like 1002.
The fixUUIDs are great as references (they're hard to enumerate), but they're not authorization. The ?owner_id= filter accepted whatever the client claimed. The fix: ignore client-supplied user identifiers entirely. The server already knows who is making the request — pull owner_id from the session, not the query string.
3
"Random" tokens that aren't · predictable identifiers
VULNERABLE

Password reset: when you request a reset, the link contains a token like ?token=YWxpY2VfMjAyNi0wNi0wNQ==. Looks opaque. Pretty random.

Use the reset link below to test. Try other base64-encoded values.

GET
No request sent yet.
Base64-decode the token: alice_2026-06-05. Construct one for another user. echo -n "bob_2026-06-05" | base64 = Ym9iXzIwMjYtMDYtMDU=.
The fixThe "token" is just a base64-encoded predictable string (username_date). Encoding is not encryption. The fix: generate cryptographically random tokens (e.g., crypto.randomBytes(32)), store the token hash in the database, validate by lookup, expire after a short window, and tie the token to the session that requested it.
4
Mass assignment · fields the API didn't expect
VULNERABLE

Update your profile: PUT /api/profile with a JSON body. The form sends name and email. What does the server accept?

PUT
No request sent yet.
Try adding a field the UI doesn't show, like "role":"admin" or "user_id":1: /api/profile {"name":"Alice","role":"admin"}.
The fixThe API was naively mapping the entire JSON body onto the user model — including fields the client should never set. Mass assignment is the formal name. The fix: use an explicit allow-list of fields per endpoint (e.g., permitted = ['name', 'email']), or define a separate input DTO that doesn't include privileged fields. Frameworks: Rails strong parameters, Django ModelForm field whitelist, Pydantic models in FastAPI.
5
Nested resource · parent check, no child check
VULNERABLE

Your project's tasks: GET /api/projects/{project_id}/tasks/{task_id}. The server checks you have access to the project. Does it check the task?

Your project is proj_42. Your task in it is task_500.

GET
No request sent yet.
Try a task ID from someone else's project (e.g., task_777) while keeping your own project ID in the URL: /api/projects/proj_42/tasks/777.
The fixThe server validated that you have access to project_42, but then looked up task_500 directly by ID — never re-verifying the task belongs to that project. The fix: scope every lookup by the parent context: db.tasks.find({id: task_id, project_id: project_id}). If the task doesn't belong to the project, the lookup returns nothing and the response is 404 — no cross-project leakage.
6
Direct object storage · predictable upload paths
VULNERABLE

Document downloads are served from https://heliotrope-uploads.s3.amazonaws.com/users/1001/payslip-2026-06.pdf. The presigned URL expires, but the underlying path is the user's ID + filename.

Two attack avenues: predict another user's path, or check whether the bucket itself is misconfigured.

GET
No request sent yet.
Try another user's path: https://heliotrope-uploads.s3.amazonaws.com/users/1002/payslip-2026-06.pdf.
The fixTwo failures stacked. (1) the bucket is public — or the per-object ACL is. (2) even if the bucket weren't public, the paths are predictable. The fix: block all public access at the bucket level (S3 Block Public Access), serve files through a presigned-URL endpoint that re-checks authorization on each request, and use unguessable filenames (UUID-based) so a misconfiguration doesn't immediately become enumeration.

The common root cause

All six bugs are variations of one mistake: using a request-supplied identifier to look up data without verifying the caller is authorized to access that specific record. The shape of the identifier changes — integer, UUID, token, S3 path, parent/child relationship — but the missing check is the same.

The universal fix pattern

Every IDOR fix is some variation of this pseudocode:

def get_resource(resource_id, session):
    resource = db.find(resource_id)
    if not resource:
        return 404
    if not user_can_access(session.user_id, resource):
        return 403
    return resource

The user_can_access function is the entire game. Make it correct, make it called everywhere, and IDOR is solved.

The point

IDOR is the most common access-control vulnerability in modern web apps because it requires nothing technical to exploit — just curiosity about whether changing a number in a URL returns someone else's data. The defenses are equally unglamorous: check authorization on every request, against the session's user identity, with a default-deny policy.

Bug bounty programs pay handsomely for IDOR findings because they keep being found, often in mature, well-funded applications. The pattern is the bug. The pattern is the fix. The discipline is doing it everywhere.

References

Formatted in APA 7. Alphabetized by first author's last name.

  1. OWASP Foundation. (2021). OWASP top 10: A01:2021 Broken access control. https://owasp.org/Top10/A01_2021-Broken_Access_Control/
  2. OWASP Foundation. (n.d.). Authorization cheat sheet. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
  3. OWASP Foundation. (n.d.). Insecure direct object reference prevention cheat sheet. https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html