Web Application Security · intermediate · ~12 min

Broken access control and IDOR

**What you will learn** - Explain what *broken access control* is and why OWASP ranks it the #1 web risk (A01). - Identify **IDOR** (Insecure Direct Object Reference), vertical/horizontal privilege escalation, and forced browsing in a request. - Test — in an authorized lab only — whether the server verifies *this user may access this object*. - Write the correct fix: server-side, per-object, deny-by-default authorization checks. - Verify a fix by proving it **rejects** another user's request and **accepts** the legitimate one. - Log access-control decisions for detection without leaking secrets.

Overview

Security objective. The asset you are protecting is per-user data and privileged actions — one customer's invoices, another patient's records, an admin-only "delete account" endpoint. The threat is a logged-in but unauthorized user (or a low-privilege account) reaching data or functions that belong to someone else. In this lesson you will learn to detect the missing check and, more importantly, to prevent it.

Broken access control means the server fails to enforce what a user is allowed to do. Authentication answered "who are you?"; authorization answers "are you allowed to touch this?" When that second check is missing or wrong, any authenticated user can act outside their permissions.

IDOR is the classic form: the app exposes a record by its identifier (/api/invoices/1002) and trusts that identifier without checking ownership. Change the ID, get someone else's data.

This builds directly on your prereqs. Authentication, authorization, and account flows (web-auth-flows) established the difference between proving identity and granting permission — access control is the authorization half done wrong. Testing the web: intercepting and shaping requests (websec-testing-http) gave you the tool to see and modify the exact IDs, body fields, and cookies that carry object references; here you use that skill to ask, for each reference, "does the server actually check this?"

Everything below runs only against systems you own or are explicitly authorized to test — a local deliberately-vulnerable app on localhost, a container, or a CTF box. Never against a live third-party site.

Why it matters

Access-control flaws are simultaneously the most common and among the most damaging web bugs, which is why OWASP moved them to the #1 slot (A01:2021). They need no clever exploit chain — no injection, no memory corruption, no cryptography break. An attacker just changes a number in a request they were already allowed to send.

In authorized professional work this matters because:

  • Impact is direct and total. A single IDOR can expose every record in a table — millions of invoices, messages, or medical files — one incremented ID at a time. Real breaches have leaked entire customer bases this way.
  • They are easy to miss. The vulnerable code often looks fine: it authenticates the user, fetches the object, returns it. The missing line is the ownership check, which is an absence, not a bug you can see.
  • Regulatory and contractual stakes are high. Cross-tenant data exposure triggers breach-notification laws (GDPR, HIPAA, PCI DSS) and voids customer trust.
  • Prevention is cheap once you know the pattern. A penetration tester who can reliably find and clearly explain the fix for access-control gaps prevents far more damage than one who only files "I changed an ID and it worked." The value is in the remediation.

Core concepts

Each concept below has a definition, a plain explanation, how it works, when it applies, and a pitfall.

1. Authorization vs. authentication

  • Definition. Authentication verifies identity ("you are user 42"). Authorization verifies permission ("user 42 may read invoice 1001").
  • Plain explanation. Logging in is the front door; authorization is the lock on each room. Broken access control is a building where the front door works but the room doors are all unlocked.
  • How it works. After authentication, the server should compare the authenticated identity against the specific object or action requested, on every request.
  • When it applies. Every endpoint that touches user-specific data or privileged actions.
  • Pitfall. Assuming "they're logged in, so it's fine." Login is necessary but never sufficient.

2. IDOR (Insecure Direct Object Reference)

  • Definition. The app references an internal object directly by an identifier supplied by the client and does not check that the caller owns or may access that object.
  • Plain explanation. The URL or body says which record to fetch, and the server obeys without asking "is this yours?"
  • How it works. GET /api/invoices/1002 → server runs SELECT * FROM invoices WHERE id = 1002 → returns it. The query filtered by id but not by owner.
  • When it applies / when not. Applies wherever a client-controlled value selects a record: path segments, query params, JSON body fields, hidden form fields, cookies. It is not IDOR if the server re-scopes the query to the current user (see the fix).
  • Pitfall. Thinking UUIDs fix it. Random IDs make guessing harder but are still IDOR if they leak (in emails, referrer headers, another endpoint) — obscurity is not authorization.

3. Horizontal vs. vertical privilege escalation

  • Definition. Horizontal = reaching another user's data at the same privilege level (peer to peer). Vertical = a low-privilege user reaching higher-privilege functions (user to admin).
  • Plain explanation. Horizontal is reading your neighbor's mail; vertical is getting the building manager's master key.
  • How it works. Horizontal: swap another user's object ID. Vertical: call an admin endpoint (POST /api/admin/users/42/delete) with a normal user's session.
  • When it applies. Horizontal on any per-user resource; vertical wherever roles gate functionality.
  • Pitfall. Hiding the admin button in the UI while leaving the admin endpoint unprotected. The endpoint is the real boundary.

4. Forced browsing

  • Definition. Requesting URLs or endpoints the UI never links to, relying on the server to enforce access rather than on them being hidden.
  • Plain explanation. "You can't see the link" is not "you can't reach the page."
  • How it works. Guessing or discovering paths (/admin, /api/v1/internal/export) and calling them directly.
  • Pitfall. "Security through obscurity" — assuming unlinked means unreachable.

Threat model

                        TRUST BOUNDARY (network edge)
                                   |
   Authenticated but              |        ENTRY POINTS
   UNAUTHORIZED user     ---------->  GET /api/invoices/{id}
   (valid session for            |     POST /api/admin/... 
    account A, wants B's         |     body field "user_id": ...
    data or admin power)         |     cookie "role": ...
                                   |
  =================================|=================================
   TRUSTED SERVER SIDE            |
                                   v
   [ authN: is the session valid? ]  --- usually PASSES ---
                                   |
   [ authZ: may THIS user touch    ]  <-- THE BOUNDARY THAT
     THIS object/action?          ]      IS OFTEN MISSING
                                   |
                                   v
   ASSETS: per-user records (invoices, messages, PII),
           admin functions (delete user, change role)

The defended boundary is the authZ check on the trusted server side. The entry points are every client-controlled reference. The asset is per-user data and privileged actions.

Knowledge check

  1. What asset is protected here, and where is the trust boundary? (Answer: per-user records and admin actions; the boundary is the server-side authorization check — the client and everything it sends is untrusted.)
  2. An endpoint authenticates the user and then returns the requested invoice. What insecure assumption makes it an IDOR? (Answer: that a valid session implies permission for that specific object — authentication was treated as authorization.)
  3. Why must you only test ID-swapping in an authorized lab? (Answer: on a real third-party system, retrieving another person's record is unauthorized access to data — a crime and an ethics violation — regardless of how easy it was.)

Syntax notes

The vulnerability and its fix live in one place: the data query. The difference is whether the current user is part of the WHERE clause.

# INSECURE — selects by object id only (lab illustration)
SELECT * FROM invoices WHERE id = :requested_id;

# SECURE — re-scopes to the authenticated owner
SELECT * FROM invoices
WHERE id = :requested_id
  AND owner_id = :current_user_id;   <- ownership enforced in the query

Key structural rule for any handler:

handler(request):
    user   = authenticate(request)          # who are you?
    if user is None: return 401
    object = load(request.id)               # what did you ask for?
    if not authorized(user, object, action):# MAY YOU? deny by default
        log_denied(user, object, action)
        return 404 or 403
    return object

Note: returning 404 (not found) rather than 403 (forbidden) for objects the user may not see avoids confirming that the ID exists — a small anti-enumeration hardening. Pick one policy and apply it consistently.

Lesson

Broken access control is the app failing to enforce what a user may do. It is consistently the #1 web risk (OWASP Top 10, A01).

IDOR (Insecure Direct Object Reference)

This is the classic case. The app exposes a record by its ID and trusts that ID without checking ownership.

GET /api/invoices/1001   <- yours
GET /api/invoices/1002   <- someone else's, and the server returns it

Change the identifier, and you get another user's data.

IDs can appear in URLs, request body fields, and cookies. All are candidates.

Predictable IDs (like 1001, 1002) make the attack trivial. Even UUIDs are vulnerable if they leak or can be enumerated.

Other access-control failures

  • Vertical escalation: a normal user reaches admin functions (/admin, an admin-only API) that are merely hidden, not protected.
  • Forced browsing: requesting URLs the UI never links to.
  • Method or parameter flaws: an action is allowed through one path but not checked on another.

How to test

For every object reference and every privileged action, ask one question:

Does the server verify that THIS user may access THIS object?

To check, try another user's IDs. Try admin endpoints as a low-privilege user.

The fix

Enforce authorization on the server, for every request.

Check the authenticated user against the specific object or action being requested.

Never rely on a hidden UI, obscure IDs, or client-side checks. Deny by default.

Code examples

The example is a small Flask-style pseudocode/Python service. Run it only in a local, isolated lab.

(1) INSECURE version

# WARNING: intentionally vulnerable — use only in a local, isolated,
# authorized lab. Do not deploy.

from flask import Flask, session, jsonify, abort
import sqlite3

app = Flask(__name__)

def db():
    conn = sqlite3.connect("lab.db")
    conn.row_factory = sqlite3.Row
    return conn

@app.route("/api/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
    if "user_id" not in session:      # authentication only
        abort(401)
    conn = db()
    # BUG: selects by id alone — no ownership check (IDOR)
    row = conn.execute(
        "SELECT id, owner_id, amount FROM invoices WHERE id = ?",
        (invoice_id,),
    ).fetchone()
    conn.close()
    if row is None:
        abort(404)
    return jsonify(dict(row))

A logged-in user requesting /api/invoices/1002 gets invoice 1002 even if they own only 1001. That is horizontal escalation via IDOR.

(2) SECURE version

@app.route("/api/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
    if "user_id" not in session:
        abort(401)
    current_user = session["user_id"]
    conn = db()
    # FIX: re-scope the query to the authenticated owner.
    row = conn.execute(
        "SELECT id, owner_id, amount FROM invoices "
        "WHERE id = ? AND owner_id = ?",
        (invoice_id, current_user),
    ).fetchone()
    conn.close()
    if row is None:
        # 404 for both 'not found' and 'not yours' — no enumeration hint.
        app.logger.info(
            "access_denied user=%s resource=invoice/%s result=deny",
            current_user, invoice_id,
        )
        abort(404)
    app.logger.info(
        "access_ok user=%s resource=invoice/%s result=allow",
        current_user, invoice_id,
    )
    return jsonify(dict(row))

The ownership condition (AND owner_id = ?) makes the database enforce authorization: a row is returned only if it belongs to the caller. This is deny by default — anything not matching the owner simply does not come back.

(3) VERIFY — prove the fix rejects bad input and accepts good input

# tests/test_authz.py  — run against the lab app only.
# Seed: invoice 1001 owned by user A (id=1), invoice 1002 owned by user B (id=2).

def test_owner_can_read_own(client, login_as):
    login_as(user_id=1)                       # user A
    r = client.get("/api/invoices/1001")
    assert r.status_code == 200               # ACCEPT good input
    assert r.get_json()["owner_id"] == 1

def test_user_cannot_read_others(client, login_as):
    login_as(user_id=1)                       # user A asks for B's invoice
    r = client.get("/api/invoices/1002")
    assert r.status_code == 404               # REJECT cross-user access

def test_unauthenticated_rejected(client):
    r = client.get("/api/invoices/1001")
    assert r.status_code == 401               # REJECT no session

Expected result. Against the insecure app, test_user_cannot_read_others fails (it returns 200 with user B's data). Against the secure app, all three tests pass: the legitimate owner gets 200, the cross-user request gets 404, and the anonymous request gets 401. That contrast is your mitigation-verification evidence.

Line by line

Walking the secure handler, following a request from user A (user_id = 1) for /api/invoices/1002 (owned by user B):

  1. if "user_id" not in session: abort(401) — authentication gate. A has a valid session, so this passes. (Authentication alone is not the boundary.)
  2. current_user = session["user_id"] — the identity is taken from the server-side session, never from a client-supplied field. This is essential: if you read the owner from the request, the attacker controls it.
  3. SELECT ... WHERE id = ? AND owner_id = ? with (1002, 1) — the database looks for an invoice whose id is 1002 and whose owner is 1. Invoice 1002's owner is 2, so no row matches.
  4. row is None is true → log the denial → abort(404). A learns nothing about whether 1002 exists.

Now the same handler for the legitimate case, user B (user_id = 2) requesting /api/invoices/1002:

Step Value Result
session check user_id = 2 present pass
current_user 2 from session
query params (1002, 2) id and owner both match
row invoice 1002 not None
response log allow, return JSON 200 with B's own data

The single line that changed everything is the AND owner_id = ? predicate combined with reading current_user from the trusted session. The insecure version simply omitted that predicate, so step 3 always matched by id alone.

Common mistakes

Real, recurring access-control mistakes:

1. Checking authentication and calling it authorization.

  • Wrong: if logged_in: return object.
  • Why wrong: being logged in says who, not whether they may. Every authenticated user can then read every object.
  • Corrected: add the per-object ownership/role check (AND owner_id = current_user, or an explicit authorized(user, object) call).
  • Recognise/prevent: grep handlers for object loads that aren't followed by an ownership predicate; add an authZ test per endpoint.

2. Trusting a client-supplied identity.

  • Wrong: reading owner_id or role from the request body, query string, or a cookie.
  • Why wrong: the client can set it to anything, so the "check" checks the attacker's own claim.
  • Corrected: derive identity and role from the server-side session/token, never from mutable request fields.
  • Recognise/prevent: any request.body["user_id"] used for authorization is a red flag.

3. Relying on hidden UI or obscure IDs.

  • Wrong: removing the admin button, or using UUIDs, and assuming the endpoint is safe.
  • Why wrong: endpoints are reachable directly; IDs leak. Obscurity is not a control.
  • Corrected: enforce the check on the server for every path, regardless of what the UI shows.
  • Recognise/prevent: forced-browsing tests — call unlinked endpoints as a low-privilege user in the lab.

4. Enforcing on GET but not on the mutating verb.

  • Wrong: checking ownership on GET /invoices/{id} but not on POST /invoices/{id}/pay or DELETE.
  • Why wrong: attackers use whichever method is unguarded; write flaws are worse than read flaws.
  • Corrected: apply the same authZ to every method and every parameter that reaches the object.
  • Recognise/prevent: enumerate all verbs per resource and test each.

5. Client-side only checks.

  • Wrong: the front-end JavaScript hides or disables actions the user can't perform.
  • Why wrong: the client is fully attacker-controlled; the request still reaches the server.
  • Corrected: treat client checks as UX only; the server must re-enforce every decision.

Debugging tips

When an access-control fix seems not to work, or a test surprises you:

  • Symptom: cross-user request still returns 200. Confirm the query actually includes the owner predicate and that current_user comes from the session, not the request. A common slip is filtering by an owner_id that was itself read from client input.
  • Symptom: the legitimate owner gets 404. Check the seed data — is the invoice's owner_id really the logged-in user's id? Mismatched types (string "1" vs int 1) also cause false denials; normalize both sides.
  • Symptom: 401 when you expected 403/404. The session isn't being sent or recognized. Verify the test client actually logs in and carries the cookie.
  • Symptom: fix works for GET, breaks for POST. Each handler enforces independently; a shared decorator or middleware avoids per-handler drift. Confirm the decorator is applied to every route.
  • General method. Reproduce with two accounts (A and B) in the lab. As A, request B's object. Before the fix: 200 (bug confirmed). After the fix: 404/403 (fixed). Then confirm A can still read A's own object (no over-blocking).

Questions to ask when it fails: Where does the identity come from — session or request? Is the ownership check in the query or only in a comment? Does every HTTP method share the check? Did I verify both rejection of bad input and acceptance of good input?

Memory safety

Security & safety — detection and logging.

Access-control abuse is detectable if you log authorization decisions well.

Log for every access decision:

  • Timestamp (with timezone).
  • Source: authenticated user/subject id, source IP, user-agent.
  • Resource requested (e.g., invoice/1002) and the action/method.
  • Result: allow or deny, and the reason code.
  • A correlation/request id so one user's activity can be traced across log lines.

Never log: passwords, session cookies, bearer/API tokens, private keys, full payment card numbers (mask to last 4), or PII you don't strictly need. Logging a token to help debug an authZ bug creates a new credential-theft vector.

Events that signal abuse:

  • A single account requesting many sequential object ids (1001, 1002, 1003, ...) — classic IDOR enumeration.
  • A spike in deny results from one user or IP.
  • A low-privilege account hitting admin endpoints.
  • Access to objects the user has never legitimately touched before (large jump in distinct resource ids per session).

How false positives arise: legitimate batch/export tools, an admin doing support work, shared service accounts, or a customer paginating quickly can all look like enumeration. Tune with rate thresholds, allowlist known service accounts, and correlate with role before alerting — an alert that fires on every support agent will be ignored. Detection supplements the server-side check; it never replaces it. And decoding/inspecting a request tells you what was asked, not whether it was authorized — only the server decision does.

Real-world uses

Authorized real-world use case. A SaaS company hires a penetration tester (with a signed scope and written authorization) to assess a multi-tenant billing app in a staging environment. The tester creates two tenant accounts, intercepts the invoice request, swaps tenant A's invoice id for one belonging to tenant B, and observes B's data returned — a cross-tenant IDOR. The deliverable isn't just "I did it"; it's a finding with the missing ownership predicate, a secure code fix, a retest proving cross-tenant access now returns 404, and logging guidance to detect future attempts.

Professional best-practice habits.

Habit Beginner Advanced
Validation Check ownership in the query for one endpoint Centralize authZ in middleware/policy layer covering all endpoints and methods
Least privilege Give test accounts only the roles they need Enforce role- and attribute-based access control (RBAC/ABAC), default-deny
Secure defaults Return 404 for unauthorized objects Deny-by-default framework where a route without an explicit policy is unreachable
Logging Log allow/deny with user + resource Ship logs to a SIEM, alert on enumeration and privilege-jump patterns
Error handling Avoid leaking whether an id exists Consistent responses that don't confirm existence, tested automatically

Beginners should first make one endpoint correct and prove it with a test. Advanced practitioners push the check out of individual handlers into a shared, tested policy layer so a new endpoint can't accidentally ship without authorization — the most reliable way to prevent the next IDOR.

Practice tasks

All tasks run only against a local, deliberately-vulnerable lab app you control (localhost/container/CTF). Authorization checklist before starting: (a) the target is yours or you have explicit written permission; (b) it runs locally/isolated; (c) you use only test accounts and seed data; (d) you will reset the lab afterward.

Beginner 1 — Spot the missing check.

  • Objective: read a handler and identify why it is an IDOR.
  • Requirements: given the insecure get_invoice handler, write one sentence naming the missing check and one sentence stating the fix.
  • Concepts: authorization vs authentication; ownership predicate.
  • Hint: look at what the WHERE clause filters by — and what it doesn't.

Beginner 2 — Confirm in the lab.

  • Objective: demonstrate horizontal escalation on the lab app.
  • Requirements: seed two accounts (A owns 1001, B owns 1002). Logged in as A, request 1002. Record the status code and whether B's data returned.
  • Input/output: input GET /api/invoices/1002 as A; expected (vulnerable) output: 200 with B's data.
  • Constraints: lab only; test accounts only.
  • Defensive conclusion: state that the correct behavior is 404, and that the remediation is the owner predicate.

Intermediate 1 — Remediate and retest.

  • Objective: apply the fix and prove it.
  • Requirements: add AND owner_id = ? sourced from the session; write two tests — A reading 1001 returns 200, A reading 1002 returns 404. Both must pass.
  • Concepts: deny by default; mitigation verification.
  • Hint: read identity from the session, never from the request.

Intermediate 2 — Cover every verb.

  • Objective: find an endpoint protected on GET but not on a mutating method.
  • Requirements: add a POST /api/invoices/{id}/pay handler missing the check; write a test showing A can pay B's invoice, then fix it and show the test now returns 404/403.
  • Concepts: method coverage; write flaws.
  • Defensive conclusion: document that authZ must apply to every method, and verify with a per-verb test.

Challenge — Centralize and detect.

  • Objective: move authorization into a shared policy layer and add abuse detection.
  • Requirements: implement a decorator/middleware that enforces ownership for any route declaring the object type, so a new route without a policy is denied by default. Add logging of allow/deny with user, resource, result, and correlation id. Write a small check that flags a single user requesting 10+ sequential ids within a short window.
  • Constraints: never log tokens/cookies/PII; lab only.
  • Concepts: deny-by-default framework, RBAC, detection/logging, false-positive tuning.
  • Defensive conclusion: verify that (1) a policy-less route is unreachable, (2) cross-user access is denied and logged, and (3) the enumeration flag fires on sequential-id sweeps but tolerates a whitelisted export account. Lab cleanup/reset: drop and re-seed lab.db, clear session/cookie stores, and delete test log files so no seeded PII or test tokens persist.

Summary

Main concepts. Broken access control (OWASP A01) is the server failing to enforce what a user may do. Authentication is who you are; authorization is whether you may. IDOR trusts a client-supplied object id without an ownership check; horizontal escalation reaches a peer's data, vertical escalation reaches admin functions, and forced browsing reaches unlinked endpoints. The trust boundary is the server-side authZ check — the client and everything it sends are untrusted.

Key syntax/commands. The fix is one predicate: WHERE id = :id AND owner_id = :current_user, with current_user taken from the server session, deny by default. Structure every handler as authenticate → load → authorize → respond, returning 404 for objects the user may not see.

Common mistakes. Treating login as permission; trusting client-supplied identity/role; relying on hidden UI or obscure/UUID ids; guarding GET but not the mutating verbs; enforcing only on the client.

What to remember. Check authorization on the server, for every request, every object, and every method — deny by default. Verify each fix by proving it rejects a cross-user request and accepts the legitimate one. Log allow/deny decisions (user, resource, result, correlation id) but never tokens, passwords, or PII. Test only on systems you own or are authorized to test, in an isolated lab, and reset afterward. And remember: no scanner, hidden link, or obscure id makes a system "secure" — only an enforced server-side decision does.

Practice with these exercises