Broken access control — IDOR and the 'just check ownership' gap
Authentication answers "who is this," but a genuinely common, real vulnerability lives in the gap right after that: checking that a user is LOGGED IN is not the same as checking that they're allowed to access THIS SPECIFIC resource, and skipping the second check is what makes IDOR possible.
4 min read
The vulnerability: authenticated, but not actually authorized for THIS resource
@app.route("/invoices/<invoice_id>")
@login_required # checks: is SOMEONE logged in — but not WHICH invoices they own
def get_invoice(request, invoice_id):
invoice = db.get_invoice(invoice_id) # fetches ANY invoice, by ID, with no ownership check at all
return jsonify(invoice)@login_required correctly verifies that a request comes from a genuinely authenticated user — but it says nothing about whether that specific user is allowed to see this specific invoice. A logged-in user, authenticated correctly, can simply change the invoice_id in the URL to a number that isn't theirs — /invoices/1042 becomes /invoices/1043 — and the endpoint happily returns someone else's invoice, since nothing in this code ever checks who the invoice actually belongs to.
IDOR: Insecure Direct Object Reference — the name for exactly this gap
"Direct object reference" = the invoice_id in the URL directly identifies
a specific record in the database, with no indirection at all
"Insecure" = nothing verifies the CURRENT user is actually allowed to
access the record that ID points to — the reference itself is trusted
implicitly, just because the user happens to be logged in
IDOR (Insecure Direct Object Reference) is the standard name for exactly this pattern: an identifier in a URL or request body directly points at a specific database record, and the application trusts that reference without separately verifying the current user is actually permitted to access that particular record. This is a genuinely common, real vulnerability precisely because it's easy to write — the code "works" perfectly for the legitimate case (a user viewing their own invoice) and only reveals the gap when someone deliberately tries a different ID than their own.
The fix: an explicit ownership (or permission) check, on every access
@app.route("/invoices/<invoice_id>")
@login_required
def get_invoice(request, invoice_id):
invoice = db.get_invoice(invoice_id)
if invoice is None or invoice.user_id != request.user.id: # THE actual missing check
abort(404) # deliberately 404, not 403 — see below
return jsonify(invoice)The fix is a real, explicit check comparing the resource's actual owner against the currently authenticated user — not just "is someone logged in," but "does this logged-in user actually own this specific record." This check has to happen on every single access to a resource identified by a direct reference, not just once somewhere earlier in a request's lifecycle, since the vulnerability is specifically about which record is being accessed, not whether the user is authenticated at all.
Why returning 404 (not 403) for an unauthorized resource is a deliberate, real choice
403 Forbidden = "this resource exists, and you're not allowed to see it"
— CONFIRMS to an attacker that invoice #1043 genuinely exists
404 Not Found = "nothing here" — doesn't confirm OR deny that the
resource exists at all, giving an attacker probing for valid IDs
no extra information either way
Returning 403 Forbidden for a resource that exists but isn't the current user's leaks real information — it confirms to anyone probing IDs that the resource genuinely exists, even if they can't see its contents, which can help an attacker map out what's real. Returning 404 Not Found for both "doesn't exist" and "exists, but not yours" gives an attacker no way to distinguish the two cases, closing this specific, real information-leak channel — a small but genuinely deliberate detail worth getting right.
Why this scales beyond simple ID guessing: it's the same gap in more complex forms
# The SAME underlying gap, showing up differently:
@app.route("/admin/users", methods=["GET"])
@login_required # checks LOGGED IN — but not whether this user is actually an ADMIN
def list_all_users(request):
return jsonify(db.get_all_users())The exact same underlying gap — checking authentication without checking the specific permission actually required — shows up in role-based contexts too: an endpoint gated only by @login_required when it should also verify the current user actually holds an admin role is the same category of bug as IDOR, just checking a role instead of a resource ID. The recurring, real principle across every variant: authentication answers "who is this," and every single protected action needs its own, explicit authorization check answering "is this specific user actually allowed to do this specific thing" — never assumed just because they're logged in at all.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does `@login_required` alone fail to prevent one user from accessing another user's invoice by changing the URL's ID?
2. Why is returning 404 (rather than 403) for a resource that exists but isn't the current user's a deliberate security choice?
3. How does an admin-only endpoint gated only by @login_required represent the same underlying gap as IDOR?