Learning Goals
3 minBy the end of this lesson you can:
- Explain IDOR and missing function-level access control.
- Recognise vulnerable code that trusts the client to enforce access.
- Apply the fix: verify ownership and role server-side on every request.
- Adopt "deny by default" and never rely on hidden UI for security.
Warm-Up · The ?id=124 Attack
5 minYou're logged in and view your invoice at /invoice?id=123. Out of curiosity you change it to 124 — and you're looking at someone else's invoice. No hacking tools, just a different number. That's Insecure Direct Object Reference (IDOR), and it's shockingly common.
Broken Access Control happens when the server authenticates you (knows who you are) but fails to authorize each action (check you're allowed to do this). The fatal assumption is "the UI only shows you your own stuff, so the request must be for your own stuff." But attackers don't use your UI — they send raw requests. The fix is simple to state and easy to forget: check authorization on the server, for every request, every time.
New Concept · The Failures & The Fix
14 minAuthentication vs. authorization
AUTHENTICATION "who are you?" → login proves identity AUTHORIZATION "are you allowed?" → checks permission for THIS action Broken access control = authentication present, authorization missing/wrong.
The two big failure modes
- IDOR — an endpoint takes an object id and returns it without checking you own it:
/api/orders/124returns order 124 regardless of whose it is. - Missing function-level control — an admin action (
/admin/delete-user) isn't protected by a role check, so any logged-in user (or anyone) can call it directly.
Vulnerable code (the trap)
# VULNERABLE — trusts the id, never checks ownership (IDOR) @app.get("/invoice/<int:invoice_id>") def view_invoice(invoice_id): invoice = db.get_invoice(invoice_id) # fetches ANY invoice by id return render(invoice) # no "is this MINE?" check! # VULNERABLE — admin route with no role check @app.post("/admin/delete-user/<int:user_id>") def delete_user(user_id): db.delete_user(user_id) # any caller can delete anyone! return "deleted"
Both "work" in the UI because the UI only links to your own invoices and hides the admin button from non-admins. But the endpoints enforce nothing — and that's all an attacker touches.
The fix: verify ownership and role, server-side
# FIXED — check the object belongs to the current user @app.get("/invoice/<int:invoice_id>") @login_required def view_invoice(invoice_id): invoice = db.get_invoice(invoice_id) if invoice is None or invoice.owner_id != current_user.id: abort(404) # 404, not 403 — don't reveal it exists return render(invoice) # FIXED — require the admin role for admin actions @app.post("/admin/delete-user/<int:user_id>") @login_required @require_role("admin") # deny by default unless admin def delete_user(user_id): db.delete_user(user_id) return "deleted"
- Ownership check —
invoice.owner_id != current_user.id → abort. The server decides, not the URL. - Role check — a
@require_roledecorator on privileged routes. - 404 over 403 for objects — returning "not found" avoids confirming the record exists to someone snooping.
The principles
- Deny by default — access is forbidden unless explicitly granted, on every route.
- Server-side only — hiding a button or disabling a field in the UI is not security; the attacker bypasses the UI entirely.
- Don't use guessable references blindly — sequential IDs invite IDOR; either authorize properly (the real fix) or use unguessable IDs as defence-in-depth (not a substitute for the check).
- Centralise checks — enforce in middleware/decorators so a forgotten route fails closed, not open.
Worked Example · Find & Fix IDOR in a Demo App
12 minGoal: a tiny local Flask app with an IDOR bug, a demonstration that one user can read another's note by changing the id, and the fix — all on your own machine.
# vulnerable_app.py — a DELIBERATELY broken demo, run locally only from flask import Flask, session, abort app = Flask(__name__) app.secret_key = "demo" # demo only NOTES = {1: {"owner": "aisha", "text": "Aisha's private note"}, 2: {"owner": "ben", "text": "Ben's private note"}} def current_user(): return session.get("user", "aisha") # pretend aisha is logged in # VULNERABLE route: @app.get("/note/<int:note_id>") def get_note_vuln(note_id): note = NOTES.get(note_id) if not note: abort(404) return note["text"] # ← no ownership check! IDOR. # FIXED route: @app.get("/secure-note/<int:note_id>") def get_note_fixed(note_id): note = NOTES.get(note_id) # deny if it doesn't exist OR isn't yours — and 404 either way if not note or note["owner"] != current_user(): abort(404) return note["text"]
# attack demo (against your OWN local app) — show the difference import requests # logged in as aisha; her own note (id 1) works on both routes. # Ben's note (id 2): print(requests.get("http://127.0.0.1:5000/note/2").text) # ← LEAKS Ben's note (vuln) print(requests.get("http://127.0.0.1:5000/secure-note/2").status_code) # 404 (fixed)
Ben's private note ← the VULNERABLE route leaks it (IDOR) 404 ← the FIXED route denies it (ownership check)
Read the code
The only difference between the two routes is one line — note["owner"] != current_user() — yet it's the difference between leaking every user's private data and a secure app. The vulnerable route "worked" in testing because the UI only ever linked aisha to note 1; the attacker simply requested /note/2 directly. This is the most common serious bug in real web apps, and the fix is always the same: authorize this request against this user, server-side. Run this on 127.0.0.1 only — it's your own deliberately-broken app.
Try It Yourself
13 minUse your own local demo app or OWASP Juice Shop (Lesson 27) — both safe, intentional targets.
Run the demo app and, as "aisha," access note 2 via the vulnerable route to confirm the leak, then via the fixed route to confirm the 404. Observe that the bug is in the endpoint, not the UI.
Add a @require_role("admin") decorator and an admin-only route (e.g. list all notes). Confirm a non-admin user gets 403/404 and an admin succeeds.
Hint
from functools import wraps from flask import abort def require_role(role): def deco(fn): @wraps(fn) def wrapper(*a, **k): if session.get("role") != role: abort(403) # deny by default return fn(*a, **k) return wrapper return deco
Review every route in an app you built: for each, ask "could another user invoke this for an object that isn't theirs?" Find at least one missing ownership/role check and fix it. Document the before/after.
Mini-Challenge · A Route Authorization Linter
8 minWrite a simple static check that scans your Flask routes and flags any that take an object id (<int:...>) but have no apparent ownership/role guard (no @login_required, no require_role, no owner_id/current_user reference in the body). It's heuristic and noisy — but it surfaces "did I forget a check?" candidates for review.
Show a sample solution
import re from pathlib import Path ROUTE = re.compile(r"@app\.(get|post|put|delete|route)\(['\"]([^'\"]+)['\"]") def lint_routes(path: str) -> None: src = Path(path).read_text(encoding="utf-8") # split into route blocks (rough heuristic) blocks = re.split(r"\n@app\.", src) for block in blocks: m = re.search(r"['\"]([^'\"]*<[^>]+>[^'\"]*)['\"]", block) if not m: continue # no object-id route route = m.group(1) has_guard = any(g in block for g in ["login_required", "require_role", "current_user", "owner_id", "abort(403", "abort(404"]) if not has_guard: print(f"⚠️ {route}: object-id route with NO visible auth check — review!") lint_routes("app.py")
Non-negotiables: flags object-id routes lacking any auth guard, acknowledges it's heuristic (review, not proof).
Recap
3 minBroken Access Control (A01, the #1 risk) is authentication without proper authorization. The two classics: IDOR (an endpoint returns an object by id without checking you own it — change ?id=123 to 124) and missing function-level control (privileged routes with no role check). The fix is always the same: verify ownership and role server-side, on every request, deny by default, return 404 for objects you can't access, and never treat hidden UI as security. Centralise checks in decorators/middleware so a forgotten route fails closed. Most real breaches are exactly this — and exactly this preventable.
Vocabulary Card
- authorization
- Checking whether an authenticated user may perform a specific action.
- IDOR
- Insecure Direct Object Reference — accessing others' objects by changing an id.
- function-level access control
- Restricting privileged actions/routes by role.
- deny by default
- Access is forbidden unless explicitly granted.
Homework
4 minTake an app you built, find a real broken-access-control issue (an unprotected route or a missing ownership check), and fix it with a server-side authorization check. Add a require_role decorator for any admin actions. Run your route linter over it. Write a before/after note explaining the bug, the fix, and why UI hiding alone was never enough.
Sample · before/after fix
Bug (A01 / IDOR): /post/<id>/edit fetched any post by id and let the
logged-in user edit it. The UI only showed edit links on your OWN
posts, so it "looked" fine — but POSTing to /post/42/edit edited
post 42 regardless of author.
Before:
@app.post("/post/<int:pid>/edit")
@login_required
def edit(pid): db.update(pid, request.form["body"]); ...
After (ownership check, deny by default):
@app.post("/post/<int:pid>/edit")
@login_required
def edit(pid):
post = db.get(pid)
if not post or post.author_id != current_user.id:
abort(404)
db.update(pid, request.form["body"]); ...
Why UI hiding wasn't enough: attackers never use my UI — they send
raw requests. Authorization MUST be enforced on the server, on every
request. The linter also flagged two other <int:id> routes I'd
forgotten to guard.Non-negotiables: a real fixed access-control bug with server-side ownership/role checks, the linter run, and the "UI hiding ≠ security" explanation.