Skip to main content
Web Security (OWASP Top 10, fix-side) · lesson 1 of 6

Also part of: Security Fundamentals Ethical Hacking (methodology, lab-only)

Broken Access Control and IDOR: Fix It in Code, Pin It with a Test

A Flask API leaks another user's invoice. Add the ownership check that fixes it, and a pytest sweep that catches the next route to forget the check.

By SkillByExample · Published

web-security owasp access-control idor flask pytest

Broken access control is number one in the OWASP Top 10. Its most common form is an IDOR: the API checks who you are and then forgets to check whether this record is yours. This lesson builds a tiny invoice API in Flask with that bug, proves it with one request and a failing test, fixes it, and adds a test that sweeps every route so the next endpoint can’t forget. To learn the attack side in depth, use PortSwigger’s access control and IDOR labs.

The lab

Everything runs on your machine, on 127.0.0.1. Make a folder, create a virtual environment and install two pinned packages:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
python -m pip install flask==3.1.3 pytest==9.1.1
$ flask --version
Python 3.12.10
Flask 3.1.3
Werkzeug 3.1.9
$ pytest --version
pytest 9.1.1

Every lesson in this topic uses this same environment.

An API that only checks who you are

Two users, two invoices, and a bearer token that maps to a user name. The route checks the token, then returns whichever invoice the URL names.

# app.py
from flask import Flask, abort, jsonify, request

app = Flask(__name__)

INVOICES = {
    1: {"id": 1, "owner": "alice", "amount": 120},
    2: {"id": 2, "owner": "bob", "amount": 4500},
}
TOKENS = {"tok-alice": "alice", "tok-bob": "bob"}


def current_user():
    user = TOKENS.get(request.headers.get("Authorization", ""))
    if user is None:
        abort(401)
    return user


@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
    current_user()  # authenticated, but never asks "is this yours?"
    invoice = INVOICES.get(invoice_id) or abort(404)
    return jsonify(invoice)

Start it with flask --app app run --port 5001, then, in a second terminal, send one request as Alice for invoice 2:

$ curl -s -H "Authorization: tok-alice" http://127.0.0.1:5001/invoices/2
{"amount":4500,"id":2,"owner":"bob"}

Alice gets Bob’s invoice. Nothing was bypassed: her token is valid, so current_user() passed, and the route never compared invoice["owner"] with her name. That’s the whole bug. The ID in the URL is input, and the server trusted it to mean “a record you’re allowed to see”.

A test that proves it

Pin the expected behaviour before fixing anything. Alice can read her own invoice, gets a 404 for Bob’s, and an anonymous request gets a 401.

# test_app.py
from app import app

client = app.test_client()
ALICE = {"Authorization": "tok-alice"}


def test_owner_reads_own_invoice():
    r = client.get("/invoices/1", headers=ALICE)
    assert r.status_code == 200
    assert r.json["owner"] == "alice"


def test_other_users_invoice_is_not_found():
    r = client.get("/invoices/2", headers=ALICE)
    assert r.status_code == 404


def test_anonymous_is_rejected():
    assert client.get("/invoices/1").status_code == 401
$ pytest -q --tb=short
.F.                                                                      [100%]
================================== FAILURES ===================================
____________________ test_other_users_invoice_is_not_found ____________________
test_app.py:16: in test_other_users_invoice_is_not_found
    assert r.status_code == 404
E   assert 200 == 404
E    +  where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
=========================== short test summary info ===========================
FAILED test_app.py::test_other_users_invoice_is_not_found - assert 200 == 404
1 failed, 2 passed in 0.17s

Two tests pass, and that’s the point: a suite that only checks the happy path and the “not logged in” path stays green with this bug. Access control needs a test written from the other user’s side. app.test_client() calls the app in-process, so the tests need no running server.

The fix: check ownership, answer 404

Replace get_invoice in app.py:

@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
    user = current_user()
    invoice = INVOICES.get(invoice_id)
    if invoice is None or invoice["owner"] != user:
        abort(404)  # same answer as "doesn't exist": no hint that id 2 is real
    return jsonify(invoice)
$ pytest -q --tb=short
...                                                                      [100%]
3 passed in 0.13s

Restart the server and ask for an invoice Alice owns, one she doesn’t, and one that doesn’t exist:

$ for id in 1 2 99; do curl -s -o /dev/null -w "invoice $id -> %{http_code}\n" -H "Authorization: tok-alice" http://127.0.0.1:5001/invoices/$id; done
invoice 1 -> 200
invoice 2 -> 404
invoice 99 -> 404

Invoice 2 and invoice 99 now look identical from outside. A 403 for invoice 2 would still tell Alice that it exists, which is enough to enumerate IDs. In a real app with a database, put the owner in the query itself (WHERE id = ? AND owner_id = ?) so a record you don’t own is never loaded at all.

The next route forgets

A month later someone adds a delete endpoint, copying the old pattern. Append it to app.py:

@app.delete("/invoices/<int:invoice_id>")
def delete_invoice(invoice_id):
    current_user()
    INVOICES.pop(invoice_id, None) or abort(404)
    return "", 204

test_app.py still passes, because it only tests GET. Instead of hoping someone writes a DELETE test, generate one test per route from Flask’s URL map:

# test_every_route.py
import pytest
from app import app

ALICE = {"Authorization": "tok-alice"}
BOBS_INVOICE_ROUTES = [
    (method, rule.rule.replace("<int:invoice_id>", "2"))
    for rule in app.url_map.iter_rules()
    if "invoice_id" in rule.arguments
    for method in sorted(rule.methods - {"HEAD", "OPTIONS"})
]


@pytest.mark.parametrize("method,url", BOBS_INVOICE_ROUTES)
def test_alice_cannot_touch_bobs_invoice(method, url):
    r = app.test_client().open(url, method=method, headers=ALICE)
    assert r.status_code == 404
$ pytest -q --tb=short test_every_route.py
.F                                                                       [100%]
================================== FAILURES ===================================
__________ test_alice_cannot_touch_bobs_invoice[DELETE-/invoices/2] ___________
test_every_route.py:17: in test_alice_cannot_touch_bobs_invoice
    assert r.status_code == 404
E   assert 204 == 404
E    +  where 204 = <WrapperTestResponse streamed [204 NO CONTENT]>.status_code
=========================== short test summary info ===========================
FAILED test_every_route.py::test_alice_cannot_touch_bobs_invoice[DELETE-/invoices/2]
1 failed, 1 passed in 0.16s

The test found two routes that take an invoice_id, GET and DELETE, without anyone listing them. GET passes and DELETE fails: Alice just deleted Bob’s invoice and got a cheerful 204. Any route added later with <int:invoice_id> in its URL joins the sweep automatically.

One helper, every route

Move the check into one function and make every route go through it. Replace both routes:

def owned_invoice_or_404(invoice_id):
    user = current_user()
    invoice = INVOICES.get(invoice_id)
    if invoice is None or invoice["owner"] != user:
        abort(404)
    return invoice


@app.get("/invoices/<int:invoice_id>")
def get_invoice(invoice_id):
    return jsonify(owned_invoice_or_404(invoice_id))


@app.delete("/invoices/<int:invoice_id>")
def delete_invoice(invoice_id):
    INVOICES.pop(owned_invoice_or_404(invoice_id)["id"])
    return "", 204
$ pytest -q --tb=short
.....                                                                    [100%]
5 passed in 0.11s

All five tests pass: three from test_app.py and two generated routes. The routes can no longer load an invoice without the ownership check, because the only way to get one is through owned_invoice_or_404. The sweep is the safety net for the day someone bypasses the helper anyway.

Practice

  1. Add GET /invoices that lists the caller’s invoices. Write a test that Alice’s list contains only invoice 1.
Solution

Filter by owner in the route itself, so nothing else can leak:

@app.get("/invoices")
def list_invoices():
    user = current_user()
    return jsonify([i for i in INVOICES.values() if i["owner"] == user])


def test_list_only_mine():
    r = app.test_client().get("/invoices", headers={"Authorization": "tok-alice"})
    assert [i["id"] for i in r.json] == [1]

The sweep doesn’t cover this route, since it has no invoice_id, which is why list endpoints need their own test.

  1. Add PATCH /invoices/<int:invoice_id> that updates amount, using the helper. How many tests does test_every_route.py run now?
Solution
@app.patch("/invoices/<int:invoice_id>")
def update_invoice(invoice_id):
    invoice = owned_invoice_or_404(invoice_id)
    invoice["amount"] = request.json["amount"]
    return jsonify(invoice)

Three: GET, DELETE and PATCH, all passing. The helper aborts with 404 before the route reads the request body, so Alice’s PATCH on Bob’s invoice never reaches request.json.

  1. Change the helper’s abort(404) to abort(403). Which tests fail, and what does the API now reveal?
Solution

Every “other user’s invoice” test fails: test_other_users_invoice_is_not_found and each route in the sweep, with assert 403 == 404. The tests are doing their job, because the rule was “answer as if it doesn’t exist”. With this exact helper, a missing invoice also returns 403, so nothing leaks yet. But the moment someone splits the branches into “missing → 404, not yours → 403”, a 403 confirms the ID is real.

  1. Add a test that Bob can’t read invoice 1.
Solution
def test_bob_cannot_read_alices():
    r = app.test_client().get("/invoices/1", headers={"Authorization": "tok-bob"})
    assert r.status_code == 404

It passes with the helper in place. Testing from both users’ sides catches a check that only works one way, for example if invoice["owner"] == "bob" hard-coded during debugging.

Frequently Asked Questions

What is an IDOR vulnerability?
IDOR (insecure direct object reference) is when an app takes an object ID from the request, such as /invoices/2, and returns or changes that object without checking that the caller is allowed to. Logging in is not enough: the server has to ask whether this particular record belongs to this particular user. It is the most common form of broken access control, the top category in the OWASP Top 10.
What is the difference between authentication and authorization?
Authentication answers who the caller is, for example by checking a session cookie or token. Authorization answers whether that caller may do this action on this object. An API can authenticate every request perfectly and still leak data if a route never makes the authorization check.
Should an API return 403 or 404 when a user requests someone else's record?
Returning 404 for records the caller doesn't own, exactly as for records that don't exist, avoids confirming that the ID is real, so IDs can't be enumerated. 403 is reasonable when the existence of the resource isn't secret, for example an admin page everyone knows about. Pick one rule and enforce it in a single helper so every route answers the same way.
Do random UUIDs fix IDOR?
No. Unguessable IDs make enumeration harder, but IDs leak through URLs, logs, shared links and browser history. Anyone holding a leaked UUID still gets the record if the server never checks ownership. Use random IDs as defence in depth, never as the access check.
How do I test for IDOR automatically?
Write a test that logs in as one user and requests another user's object on every route that takes an object ID, asserting a 404 or 403. Generating the list of routes from the framework's URL map, rather than listing them by hand, means a route added later is tested without anyone remembering to write a new test.