Web Application Security · intermediate · ~12 min
**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.
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.
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:
Each concept below has a definition, a plain explanation, how it works, when it applies, and a pitfall.
GET /api/invoices/1002 → server runs SELECT * FROM invoices WHERE id = 1002 → returns it. The query filtered by id but not by owner.POST /api/admin/users/42/delete) with a normal user's session./admin, /api/v1/internal/export) and calling them directly. 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
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.
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).
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.
/admin, an admin-only API) that are merely hidden, not protected.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.
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.
The example is a small Flask-style pseudocode/Python service. Run it only in a local, isolated lab.
# 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.
@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.
# 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.
Walking the secure handler, following a request from user A (user_id = 1) for /api/invoices/1002 (owned by user B):
if "user_id" not in session: abort(401) — authentication gate. A has a valid session, so this passes. (Authentication alone is not the boundary.)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.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.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.
Real, recurring access-control mistakes:
1. Checking authentication and calling it authorization.
if logged_in: return object.AND owner_id = current_user, or an explicit authorized(user, object) call).2. Trusting a client-supplied identity.
owner_id or role from the request body, query string, or a cookie.request.body["user_id"] used for authorization is a red flag.3. Relying on hidden UI or obscure IDs.
4. Enforcing on GET but not on the mutating verb.
GET /invoices/{id} but not on POST /invoices/{id}/pay or DELETE.5. Client-side only checks.
When an access-control fix seems not to work, or a test surprises you:
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.owner_id really the logged-in user's id? Mismatched types (string "1" vs int 1) also cause false denials; normalize both sides.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?
Security & safety — detection and logging.
Access-control abuse is detectable if you log authorization decisions well.
Log for every access decision:
invoice/1002) and the action/method.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:
1001, 1002, 1003, ...) — classic IDOR enumeration.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.
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.
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.
get_invoice handler, write one sentence naming the missing check and one sentence stating the fix.WHERE clause filters by — and what it doesn't.Beginner 2 — Confirm in the lab.
GET /api/invoices/1002 as A; expected (vulnerable) output: 200 with B's data.Intermediate 1 — Remediate and retest.
AND owner_id = ? sourced from the session; write two tests — A reading 1001 returns 200, A reading 1002 returns 404. Both must pass.Intermediate 2 — Cover every verb.
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.Challenge — Centralize and detect.
lab.db, clear session/cookie stores, and delete test log files so no seeded PII or test tokens persist.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.