TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Learning Hub

How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App

Build a small FastAPI invoicing API, attack it as an ordinary logged-in user, see why UUIDs and a one-route patch fail, then fix broken object level authorization with owner-scoped queries and a test...

October 8, 2026 35 Min Read
13

By the end of this tutorial you will have a small web API in which no logged-in user can read, change, or delete another user’s data, plus a test suite that fails the moment someone adds a route that forgets the rule. You will get there the realistic way: build the vulnerable version first, attack it as an ordinary customer, try the two fixes people reach for first and watch both fall short, and only then fix it properly.

Table Of Content

  • Key terms in plain language
  • What you will build
  • Prerequisites
  • Step 1: Create the project and install the packages
  • Step 2: Write the pieces both versions share
  • The database layer: store.py
  • Authentication: auth.py
  • A helper that runs the API for the demos: labserver.py
  • Step 3: Build the first draft of the API
  • Step 4: Attack the API as Alice
  • Step 5: Two fixes that do not fix it
  • Fix attempt 1: “Use UUIDs so nobody can guess the IDs”
  • Fix attempt 2: “Patch the route in the bug report”
  • Step 6: Write the authorization test matrix
  • Write the rules down: matrix.py
  • Turn the rules into tests: test_authz_matrix.py
  • Step 7: Fix it structurally
  • Move 1: put the owner in the query
  • Moves 2 to 5: build the routes on top of it
  • Step 8: Keep it fixed when the code grows
  • Common mistakes and gotchas
  • Comparing the token’s user ID with the ID in the URL is not enough
  • Authorize the whole chain, not just the parent
  • 403 and 404 are not interchangeable
  • Every input is an input, not only the path
  • Roles are a separate question
  • An inventory that cannot see your routes passes forever
  • One object proves little
  • Watch the denials
  • Confirm that everything works end to end
  • Where to go from here
  • Sources

The flaw has two common names. Security teams call it broken object level authorization, or BOLA, which is how OWASP lists it in its API Security Top 10 (entry API1:2023). Developers usually call it an insecure direct object reference, or IDOR. Both describe the same mistake: the API takes an identifier from the client, such as an invoice number, finds the matching record, and returns it without asking whether this caller is allowed to have it. MITRE files the root cause as CWE-639: “The system’s authorization functionality does not prevent one user from gaining access to another user’s data or record by modifying the key value identifying the data.”

It matters because it is everywhere. OWASP’s Top 10:2025 keeps broken access control at number one and reports that “100% of the applications tested were found to have some form of broken access control.” The API list calls BOLA “extremely common in API-based applications” and explains why: the server relies on parameters such as object IDs that the client sends to decide which objects to access. Two earlier tutorials on this site, on server-side request forgery and cross-site request forgery, cover weaknesses that the same A01 page lists among its notable CWEs. This one covers the part of A01 that those two leave out: who owns the record.

Key terms in plain language

Authentication answers “who are you?” It is the login step, and in this tutorial it is a token in a request header. Authorization answers “are you allowed to do this to that?” It is a separate decision, and it has to be made again for every request. An object is any record your API exposes, here an invoice or an attachment, and an object ID is the value the client sends to say which one it means, usually in the URL. OWASP’s API page notes that object IDs “can be anything from sequential integers, UUIDs, or generic strings.”

Three HTTP status codes do most of the talking. 401 means “I do not know who you are.” 403 means “I know who you are, and the answer is no.” 404 means “there is nothing here.” Which of the last two you return for somebody else’s object turns out to matter, as you will see in step 5.

BOLA is an authorization failure that happens after authentication succeeded. OWASP puts it this way: “In the case of BOLA, it’s by design that the user will have access to the vulnerable API endpoint/function.” The violation happens at the object level, by changing the ID. That is why the bug survives demos and casual testing. Every user is logged in, every route works, and the problem only shows when you log in as one user and ask for another user’s object.

What you will build

  1. A small invoicing API called LedgerBox with two customers, Alice and Bob, who each own three invoices.
  2. An attack script that logs in as Alice and tries to reach Bob’s data.
  3. Two tempting fixes, UUID identifiers and a patch for the one route a bug report named, together with the evidence that neither one works.
  4. An authorization test matrix that turns the rule “you only touch your own objects” into pytest tests.
  5. The structural fix: every database query carries the owner, and every route gets its data through one scoped object.
  6. A guard that fails the build when a new route appears without an access rule.

Prerequisites

  • Python 3.10 or newer. The outputs below came from Python 3.13.14 on Windows 11. The code contains nothing Windows-specific, but I only ran it there.
  • A terminal and a text editor. You will create about a dozen small files in one folder.
  • Five Python packages, installed in step 1. SQLite ships with Python, so there is no database server to set up, and no cloud account is involved.
  • Basic familiarity with Python functions and decorators and with what GET, PATCH, and DELETE requests and status codes are. You do not need any FastAPI experience.

Step 1: Create the project and install the packages

Make an empty folder, create a virtual environment inside it so the packages stay local to this project, activate it, and install the packages. A virtual environment is a private copy of Python’s package folder; deleting the folder removes everything you installed.

mkdir ledgerbox
cd ledgerbox
python -m venv .venv

# Windows (PowerShell)
.venv\Scripts\Activate.ps1
# macOS or Linux
source .venv/bin/activate

python -m pip install fastapi==0.142.4 uvicorn==0.54.0 requests==2.34.2 pytest==9.1.1 httpx2==2.13.1

FastAPI is the web framework, uvicorn is the server that runs it, requests is the HTTP client our attack scripts use, and pytest runs the tests. The fifth package, httpx2, is the HTTP library behind FastAPI’s TestClient. Starlette 1.7.0, which FastAPI 0.142.4 installs, deprecates plain httpx for its test client and tells you to install httpx2 instead, so that is what this tutorial uses. The versions are pinned to the ones the outputs in this tutorial came from.

Check that the install worked before moving on:

python -c "import fastapi; print(fastapi.__version__)"
0.142.4

Step 2: Write the pieces both versions share

The vulnerable and the fixed versions of LedgerBox share three small files. Create each one in the project folder.

The database layer: store.py

# store.py
import random
import sqlite3
import uuid

SCHEMA = """
CREATE TABLE invoices (
    id TEXT PRIMARY KEY,
    owner TEXT NOT NULL,
    customer TEXT NOT NULL,
    amount_cents INTEGER NOT NULL,
    status TEXT NOT NULL DEFAULT 'open',
    note TEXT NOT NULL DEFAULT ''
);
CREATE TABLE attachments (
    id TEXT PRIMARY KEY,
    invoice_id TEXT NOT NULL,
    filename TEXT NOT NULL,
    content TEXT NOT NULL
);
CREATE TABLE events (
    seq INTEGER PRIMARY KEY AUTOINCREMENT,
    invoice_id TEXT NOT NULL,
    kind TEXT NOT NULL
);
"""

# (owner, customer, amount_cents, note)
INVOICES = [
    ("alice", "Acme Corp", 12500, "Q3 retainer"),
    ("alice", "Initech", 4200, ""),
    ("alice", "Hooli", 99000, "Net 30"),
    ("bob", "Globex", 31000, "Pay to IBAN GB00TEST00000099"),
    ("bob", "Umbrella", 8800, ""),
    ("bob", "Stark Industries", 450000, "Board approved, do not discuss"),
]

# (number of the invoice it belongs to, filename, content)
ATTACHMENTS = [
    (1, "terms.txt", "net-30"),
    (2, "scope.txt", "two workshops"),
    (4, "wire-instructions.txt", "Account 12345678, sort code 00-00-00"),
    (6, "contract.txt", "Confidential: acquisition pricing"),
]

COLUMNS = {"owner", "customer", "amount_cents", "status", "note"}


class Store:
    """A tiny invoice database. Every method here is unscoped: it can reach any owner's rows."""

    def __init__(self, path, id_style="int"):
        self.db = sqlite3.connect(path, check_same_thread=False)
        self.db.row_factory = sqlite3.Row
        self.db.executescript(SCHEMA)
        self.id_style = id_style
        self.counters = {"invoice": 0, "attachment": 0}
        # Seeded only so the lab prints the same UUIDs on every run. Real code uses uuid.uuid4().
        self.rng = random.Random(2026)
        self._seed()

    def new_id(self, kind):
        if self.id_style == "uuid":
            return str(uuid.UUID(int=self.rng.getrandbits(128), version=4))
        self.counters[kind] += 1
        return str(self.counters[kind])

    def _seed(self):
        ids = [self.create(*row)["id"] for row in INVOICES]
        for number, filename, content in ATTACHMENTS:
            self.db.execute(
                "INSERT INTO attachments (id, invoice_id, filename, content) VALUES (?, ?, ?, ?)",
                (self.new_id("attachment"), ids[number - 1], filename, content),
            )
        self.db.execute("INSERT INTO events (invoice_id, kind) VALUES (?, 'paid')", (ids[3],))
        self.db.commit()

    def _row(self, sql, args=()):
        row = self.db.execute(sql, args).fetchone()
        return dict(row) if row else None

    def invoices(self, owner):
        rows = self.db.execute("SELECT * FROM invoices WHERE owner = ? ORDER BY rowid", (owner,))
        return [dict(r) for r in rows]

    def invoice(self, invoice_id):
        return self._row("SELECT * FROM invoices WHERE id = ?", (invoice_id,))

    def create(self, owner, customer, amount_cents, note=""):
        invoice_id = self.new_id("invoice")
        self.db.execute(
            "INSERT INTO invoices (id, owner, customer, amount_cents, note) VALUES (?, ?, ?, ?, ?)",
            (invoice_id, owner, customer, amount_cents, note),
        )
        self.db.execute("INSERT INTO events (invoice_id, kind) VALUES (?, 'created')", (invoice_id,))
        self.db.commit()
        return self.invoice(invoice_id)

    def update(self, invoice_id, **fields):
        unknown = set(fields) - COLUMNS
        if unknown:
            raise ValueError(f"unknown columns: {sorted(unknown)}")
        if fields:
            sets = ", ".join(f"{name} = ?" for name in fields)  # names come from the COLUMNS allowlist
            self.db.execute(f"UPDATE invoices SET {sets} WHERE id = ?", (*fields.values(), invoice_id))
            self.db.commit()
        return self.invoice(invoice_id)

    def delete(self, invoice_id):
        self.db.execute("DELETE FROM attachments WHERE invoice_id = ?", (invoice_id,))
        cursor = self.db.execute("DELETE FROM invoices WHERE id = ?", (invoice_id,))
        self.db.commit()
        return cursor.rowcount > 0

    def attachment(self, attachment_id):
        return self._row("SELECT * FROM attachments WHERE id = ?", (attachment_id,))

    def activity(self, limit=5):
        rows = self.db.execute("SELECT invoice_id, kind FROM events ORDER BY seq DESC LIMIT ?", (limit,))
        return [dict(r) for r in rows]

Three tables hold the data. invoices is the main one, attachments holds files that belong to an invoice, and events is a log that a dashboard feed will read later. The seed data gives Alice invoices 1 to 3 and Bob invoices 4 to 6:

Invoice Owner Customer Amount in cents Attachment
1 alice Acme Corp 12500 1 (terms.txt)
2 alice Initech 4200 2 (scope.txt)
3 alice Hooli 99000 none
4 bob Globex 31000 3 (wire-instructions.txt)
5 bob Umbrella 8800 none
6 bob Stark Industries 450000 4 (contract.txt)

Read the Store methods with one question in mind: which of them check who is asking? None of them do. invoice(invoice_id) looks a row up by ID alone and will return anyone’s, and invoices(owner) returns the rows of whatever owner name it is handed. That is normal for a first draft, and it is the root of everything that follows.

Two details are worth knowing. First, update builds its SQL from column names, so it checks them against the COLUMNS allowlist before using them. Column names cannot be passed as query parameters, and pasting client-chosen names into SQL would open an injection hole; the SQL injection tutorial covers that class of bug. Second, new_id hands out sequential strings such as “1” and “2” by default, and UUIDs when you ask for id_style="uuid". The lab seeds its random generator only so that the UUIDs print the same on every run. Real code should call uuid.uuid4().

Authentication: auth.py

# auth.py
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

TOKENS = {"tok-alice": "alice", "tok-bob": "bob"}

bearer = HTTPBearer(auto_error=False)


def current_user(creds: HTTPAuthorizationCredentials | None = Depends(bearer)) -> str:
    """Authentication only: who is calling? Whether they may touch an object is a separate question."""
    if creds is None or creds.credentials not in TOKENS:
        raise HTTPException(
            status_code=401,
            detail="missing or invalid token",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return TOKENS[creds.credentials]

current_user reads the Authorization: Bearer header, looks the token up in a dictionary, and returns the user name or raises a 401 error. A real service would validate a signed token or a session cookie here; a dictionary keeps the tutorial focused. HTTPBearer(auto_error=False) makes FastAPI’s bearer helper return None for a missing header instead of raising its own error, so our code decides what the response looks like. Notice what this function does not do. It says who is calling and nothing about what that person may touch.

A helper that runs the API for the demos: labserver.py

# labserver.py
import threading
import time

import uvicorn


class LiveServer:
    """Run a FastAPI app on 127.0.0.1 in a background thread for the demos."""

    def __init__(self, app, port):
        config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="critical")
        self.server = uvicorn.Server(config)
        self.thread = threading.Thread(target=self.server.run, daemon=True)
        self.url = f"http://127.0.0.1:{port}"

    def __enter__(self):
        self.thread.start()
        while not self.server.started:
            if not self.thread.is_alive():
                raise RuntimeError("the server did not start (is the port already in use?)")
            time.sleep(0.02)
        return self

    def __exit__(self, *exc):
        self.server.should_exit = True
        self.thread.join(timeout=5)

This helper runs the FastAPI app with uvicorn in a background thread, so that one script can start the API, send requests to it, and shut it down again. The with block waits until the server is listening and stops it when the block ends. If you ever see the message about the port already being in use, another program has that port.

Check that the three files import cleanly:

python -c "import store, auth, labserver; print('imports ok')"
imports ok

Step 3: Build the first draft of the API

Now the API itself, written the way many first drafts are. Create app_v1.py:

# app_v1.py - LedgerBox, first draft
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel

from auth import current_user
from store import Store

router = APIRouter()


def get_store(request: Request) -> Store:
    return request.app.state.store


class InvoiceIn(BaseModel):
    customer: str
    amount_cents: int
    note: str = ""
    owner: str | None = None  # the client may name the owner


class InvoicePatch(BaseModel):
    customer: str | None = None
    amount_cents: int | None = None
    note: str | None = None
    status: str | None = None
    owner: str | None = None  # the client may change the owner


@router.get("/invoices")
async def list_invoices(owner: str | None = None, user: str = Depends(current_user), db: Store = Depends(get_store)):
    return db.invoices(owner or user)


@router.post("/invoices", status_code=201)
async def create_invoice(body: InvoiceIn, user: str = Depends(current_user), db: Store = Depends(get_store)):
    return db.create(body.owner or user, body.customer, body.amount_cents, body.note)


@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, user: str = Depends(current_user), db: Store = Depends(get_store)):
    inv = db.invoice(invoice_id)
    if inv is None:
        raise HTTPException(status_code=404, detail="invoice not found")
    return inv


@router.patch("/invoices/{invoice_id}")
async def patch_invoice(invoice_id: str, body: InvoicePatch, user: str = Depends(current_user), db: Store = Depends(get_store)):
    if db.invoice(invoice_id) is None:
        raise HTTPException(status_code=404, detail="invoice not found")
    return db.update(invoice_id, **body.model_dump(exclude_unset=True))


@router.delete("/invoices/{invoice_id}", status_code=204)
async def delete_invoice(invoice_id: str, user: str = Depends(current_user), db: Store = Depends(get_store)):
    if not db.delete(invoice_id):
        raise HTTPException(status_code=404, detail="invoice not found")


@router.get("/invoices/{invoice_id}/attachments/{attachment_id}")
async def get_attachment(invoice_id: str, attachment_id: str, user: str = Depends(current_user), db: Store = Depends(get_store)):
    att = db.attachment(attachment_id)
    if att is None:
        raise HTTPException(status_code=404, detail="attachment not found")
    return att


@router.get("/activity")
async def activity(user: str = Depends(current_user), db: Store = Depends(get_store)):
    return db.activity(5)


def create_app(db_path: str, id_style: str = "int") -> FastAPI:
    app = FastAPI(title="LedgerBox")
    app.state.store = Store(db_path, id_style)
    app.include_router(router)
    return app

If you are new to FastAPI, three pieces of vocabulary cover this file. A route is a function registered for a method and a path, for example @router.get("/invoices/{invoice_id}"); the part in braces is a path parameter that FastAPI passes to the function as an argument. Depends(current_user) tells FastAPI to run current_user first and pass its result in as the user argument, and if that function raises an error the route never runs. create_app builds the application, opens the database, and attaches the routes.

The API looks reasonable. Every route requires a login, invoices are listed, created, read, edited, and deleted, attachments hang off invoices, and there is an activity feed for a dashboard. Now read it again as an attacker would, and look for five things.

  1. The user argument is ignored. get_invoice, patch_invoice, delete_invoice, and get_attachment all receive the caller’s name and never compare it with anything. The route knows who is asking and never asks whether that person owns the invoice.
  2. The list route trusts a query parameter. GET /invoices?owner=bob returns Bob’s invoices to anyone, because owner or user prefers whatever the client sent.
  3. Request bodies can name the owner. InvoiceIn and InvoicePatch both have an owner field, so a client can create an invoice for somebody else or give an invoice away.
  4. The nested route ignores its parent. /invoices/{invoice_id}/attachments/{attachment_id} loads the attachment by its own ID and never looks at invoice_id.
  5. The activity feed is not filtered. db.activity(5) returns the five newest events across every customer.

Step 4: Attack the API as Alice

To see whether those readings are real, we will act as Alice. She is an ordinary customer with a valid token and nothing else. Create attack.py:

# attack.py - act as Alice, an ordinary logged-in customer, and probe Bob's data
import importlib
import sys
import tempfile

import requests

from labserver import LiveServer

PORTS = {"v1": 8101, "v2": 8102, "v3": 8103}
ALICE = {"Authorization": "Bearer tok-alice"}


def show(base, label, method, path, **kwargs):
    r = requests.request(method, base + path, headers=ALICE, timeout=5, **kwargs)
    text = " ".join(r.text.split())
    print(f"{label:<26}{method:<7}{path:<30}{r.status_code}  {text[:62]}")


def run(base):
    print("--- reading ---")
    show(base, "a foreign invoice", "GET", "/invoices/4")
    show(base, "a missing invoice", "GET", "/invoices/99")
    show(base, "the owner filter", "GET", "/invoices?owner=bob")
    show(base, "a foreign attachment", "GET", "/invoices/1/attachments/3")
    show(base, "the activity feed", "GET", "/activity")
    mine = {i["id"] for i in requests.get(base + "/invoices", headers=ALICE, timeout=5).json()}
    seen = [
        str(n)
        for n in range(1, 13)
        if str(n) not in mine and requests.get(f"{base}/invoices/{n}", headers=ALICE, timeout=5).status_code != 404
    ]
    print(f"ids 1-12 that are not Alice's but did not answer 404: {seen}")
    print("--- writing ---")
    show(base, "edit a foreign invoice", "PATCH", "/invoices/4", json={"note": "paid in full, owes nothing"})
    show(base, "take over an invoice", "PATCH", "/invoices/6", json={"owner": "alice"})
    show(base, "delete a foreign invoice", "DELETE", "/invoices/5")
    show(base, "plant an invoice for Bob", "POST", "/invoices", json={"customer": "Fake LLC", "amount_cents": 1, "owner": "bob"})


if __name__ == "__main__":
    version = sys.argv[1]
    module = importlib.import_module(f"app_{version}")
    with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
        with LiveServer(module.create_app(f"{tmp}/ledger.db"), PORTS[version]) as server:
            run(server.url)

The script starts the version of the app you name, with a fresh temporary database each time, and sends requests with Alice’s token. Its first half only reads. The second half writes, so it runs last. The line about ids 1 to 12 asks for every invoice number in that range and reports the ones that are not Alice’s but also did not answer 404. The script cuts each response body to 62 characters to keep the lines short. Run it against the first draft:

python attack.py v1
--- reading ---
a foreign invoice         GET    /invoices/4                   200  {"id":"4","owner":"bob","customer":"Globex","amount_cents":310
a missing invoice         GET    /invoices/99                  404  {"detail":"invoice not found"}
the owner filter          GET    /invoices?owner=bob           200  [{"id":"4","owner":"bob","customer":"Globex","amount_cents":31
a foreign attachment      GET    /invoices/1/attachments/3     200  {"id":"3","invoice_id":"4","filename":"wire-instructions.txt",
the activity feed         GET    /activity                     200  [{"invoice_id":"4","kind":"paid"},{"invoice_id":"6","kind":"cr
ids 1-12 that are not Alice's but did not answer 404: ['4', '5', '6']
--- writing ---
edit a foreign invoice    PATCH  /invoices/4                   200  {"id":"4","owner":"bob","customer":"Globex","amount_cents":310
take over an invoice      PATCH  /invoices/6                   200  {"id":"6","owner":"alice","customer":"Stark Industries","amoun
delete a foreign invoice  DELETE /invoices/5                   204  
plant an invoice for Bob  POST   /invoices                     201  {"id":"7","owner":"bob","customer":"Fake LLC","amount_cents":1

Every one of those requests carried Alice’s legitimate token, and every request aimed at Bob’s data succeeded. The only 404 is the one for the missing invoice 99. Reading down the output:

  • Reading. GET /invoices/4 returns Bob’s Globex invoice. The owner filter returns Bob’s list. The attachment route, asked for attachment 3 under Alice’s own invoice 1, returns wire-instructions.txt, which belongs to Bob’s invoice 4. The activity feed names invoices that are not hers, and the sweep finds that 4, 5, and 6 exist and are somebody else’s.
  • Writing. Alice rewrote the note on Bob’s invoice 4, took over his largest invoice (the 450000-cent Stark Industries one, which now has "owner":"alice"), deleted his invoice 5, and planted a fake invoice in his account.

The run shows each of the three harms OWASP names for this category. In its words, unauthorized access to other users’ objects “can result in data disclosure to unauthorized parties, data loss, or data manipulation.” The first draft had no clever vulnerability to exploit. It simply never asked the question.

Step 5: Two fixes that do not fix it

Teams usually reach for one of two fixes at this point. Both feel right, and both leave the hole open. It is worth watching them fail, because the failures show up in real code reviews.

Fix attempt 1: “Use UUIDs so nobody can guess the IDs”

If Alice cannot guess invoice numbers, she cannot reach Bob’s invoices. That is the theory. Create uuid_leak.py, which runs the same first-draft app with UUID identifiers:

# uuid_leak.py - unguessable IDs work until some other endpoint hands them out
import tempfile

import requests

import app_v1
from labserver import LiveServer

ALICE = {"Authorization": "Bearer tok-alice"}

with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
    app = app_v1.create_app(f"{tmp}/ledger.db", id_style="uuid")
    with LiveServer(app, 8104) as server:

        def get(path):
            return requests.get(server.url + path, headers=ALICE, timeout=5)

        mine = {i["id"] for i in get("/invoices").json()}
        print("Alice's own ids:", sorted(i[:8] for i in mine))
        codes = {get(f"/invoices/{n}").status_code for n in range(1, 501)}
        print("guessing /invoices/1 to /invoices/500 gave only these status codes:", sorted(codes))
        feed = list(dict.fromkeys(e["invoice_id"] for e in get("/activity").json()))
        print("ids named by /activity:", [i[:8] for i in feed])
        for invoice_id in feed:
            if invoice_id not in mine:
                r = get("/invoices/" + invoice_id)
                data = r.json()
                print(f"GET /invoices/{invoice_id[:8]}... -> {r.status_code} owner={data['owner']} customer={data['customer']}")
python uuid_leak.py
Alice's own ids: ['e5121482', 'f38b2ffc', 'f3f49249']
guessing /invoices/1 to /invoices/500 gave only these status codes: [404]
ids named by /activity: ['6bad6be2', '7dabe929', 'd7a7a3cc', 'e5121482']
GET /invoices/6bad6be2... -> 200 owner=bob customer=Globex
GET /invoices/7dabe929... -> 200 owner=bob customer=Stark Industries
GET /invoices/d7a7a3cc... -> 200 owner=bob customer=Umbrella

The guessing attack fails exactly as hoped: 500 guesses, all 404. Then the activity feed, which lists invoice IDs for every customer, hands Alice three of Bob’s UUIDs, and she reads all three invoices. The IDs were unguessable and she did not need to guess them. In real systems IDs can leak through feeds like this one, through shared links, logs, and screenshots, and through other API responses nobody thought of.

The standards say the same thing in two places. RFC 9562, the current UUID specification, states in its security considerations: “Implementations SHOULD NOT assume that UUIDs are hard to guess. For example, they MUST NOT be used as security capabilities (identifiers whose mere possession grants access).” OWASP’s Authorization cheat sheet adds: “While one can use various techniques to mask or randomize these IDs and make them hard to guess, such an approach is generally not sufficient by itself. A user should not be able to access a resource they do not have permissions simply because they are able to guess and manipulate that object’s identifier in a query param or elsewhere.”

That does not make UUIDs useless. OWASP’s API page recommends: “Prefer the use of random and unpredictable values as GUIDs for records’ IDs.” The IDOR prevention cheat sheet frames them correctly as defense in depth: “use complex identifiers as a defense-in-depth measure, but remember that access control is crucial even with these identifiers.” Keep them as a second layer, never as the first.

Fix attempt 2: “Patch the route in the bug report”

Now imagine a penetration test report arrives that says GET /invoices/{id} returns other customers’ invoices. A developer adds an ownership check to that route. Because attachments are also sensitive, they add a check there too. Copy app_v1.py to app_v2.py and replace these two functions, leaving everything else exactly as it was:

# app_v2.py (copy app_v1.py to app_v2.py, then replace these two functions)
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, user: str = Depends(current_user), db: Store = Depends(get_store)):
    inv = db.invoice(invoice_id)
    if inv is None:
        raise HTTPException(status_code=404, detail="invoice not found")
    if inv["owner"] != user:  # the fix for the route named in the bug report
        raise HTTPException(status_code=403, detail="not your invoice")
    return inv


@router.get("/invoices/{invoice_id}/attachments/{attachment_id}")
async def get_attachment(invoice_id: str, attachment_id: str, user: str = Depends(current_user), db: Store = Depends(get_store)):
    inv = db.invoice(invoice_id)  # checks the invoice named in the URL...
    if inv is None:
        raise HTTPException(status_code=404, detail="invoice not found")
    if inv["owner"] != user:
        raise HTTPException(status_code=403, detail="not your invoice")
    att = db.attachment(attachment_id)  # ...but loads the attachment by its own id alone
    if att is None:
        raise HTTPException(status_code=404, detail="attachment not found")
    return att

Both checks look sensible, and both answer 403 when the invoice is somebody else’s. Run the same attack script against the patched version:

python attack.py v2
--- reading ---
a foreign invoice         GET    /invoices/4                   403  {"detail":"not your invoice"}
a missing invoice         GET    /invoices/99                  404  {"detail":"invoice not found"}
the owner filter          GET    /invoices?owner=bob           200  [{"id":"4","owner":"bob","customer":"Globex","amount_cents":31
a foreign attachment      GET    /invoices/1/attachments/3     200  {"id":"3","invoice_id":"4","filename":"wire-instructions.txt",
the activity feed         GET    /activity                     200  [{"invoice_id":"4","kind":"paid"},{"invoice_id":"6","kind":"cr
ids 1-12 that are not Alice's but did not answer 404: ['4', '5', '6']
--- writing ---
edit a foreign invoice    PATCH  /invoices/4                   200  {"id":"4","owner":"bob","customer":"Globex","amount_cents":310
take over an invoice      PATCH  /invoices/6                   200  {"id":"6","owner":"alice","customer":"Stark Industries","amoun
delete a foreign invoice  DELETE /invoices/5                   204  
plant an invoice for Bob  POST   /invoices                     201  {"id":"7","owner":"bob","customer":"Fake LLC","amount_cents":1

Compare it with the first run. The first line changed from 200 to 403, so the route named in the report is fixed, and almost nothing else changed. Reading down the lines that matter:

  • Still wide open. The owner filter, the activity feed, and all four write attacks work exactly as before. The patch touched two routes out of seven.
  • The attachment route is still leaking. It now checks the invoice in the URL, but Alice asked for her own invoice 1, which passes the check, and then the code loads attachment 3 by its own ID. The check covers the parent, and the child is not bound to it.
  • The 403 gives Alice a map. A foreign invoice answers 403, a missing one answers 404, so the sweep still returns ['4', '5', '6']. She learns that exactly three invoices she does not own exist, which is business information in itself. The two answers should have been the same.

The Authorization cheat sheet describes why one-route fixes keep failing: “Remember an attacker only needs to find one way in.” It continues: “Validating permissions correctly on just the majority of requests is insufficient.” We need a way to find every way in, which is the next step.

Step 6: Write the authorization test matrix

The fix itself comes in step 7, but we will write the tests first. A test that fails on the broken versions proves it can detect the problem, and it tells us when the rules in the matrix are met. OWASP’s API page asks for this directly: “Write tests to evaluate the vulnerability of the authorization mechanism. Do not deploy changes that make the tests fail.” Its Authorization Testing Automation cheat sheet explains why the effort pays off: “most of the problems with authorizations occur because features are added/modified in updated releases without determining their effect on the application’s authorizations” Its recommendation is to “automate the evaluation of the authorizations and perform a test when a new release is created,” and it describes the list of who may do what as “an authorization matrix.”

Write the rules down: matrix.py

# matrix.py - the access rule for every route, written down once.
#
# Seed data (see store.py): Alice owns invoices 1-3 and attachments 1-2.
# Bob owns invoices 4-6 and attachments 3-4.
#
# An "object route" points at one object. The rule is always the same:
# the owner succeeds, anybody else is denied, and an anonymous caller gets 401.

OBJECT_ROUTES = {
    # (method, route template): (label, path used in the test, JSON body, status the owner should get)
    ("GET", "/invoices/{invoice_id}"): ("get invoice", "/invoices/1", None, 200),
    ("PATCH", "/invoices/{invoice_id}"): ("patch invoice", "/invoices/1", {"note": "updated"}, 200),
    ("DELETE", "/invoices/{invoice_id}"): ("delete invoice", "/invoices/1", None, 204),
    ("GET", "/invoices/{invoice_id}/attachments/{attachment_id}"): ("get attachment", "/invoices/1/attachments/1", None, 200),
}

# Routes that do not point at one object. Each has its own tests in test_authz_matrix.py.
COLLECTION_ROUTES = {
    # (method, route template): (path used in the test, JSON body)
    ("GET", "/invoices"): ("/invoices", None),
    ("POST", "/invoices"): ("/invoices", {"customer": "Test Ltd", "amount_cents": 100}),
    ("GET", "/activity"): ("/activity", None),
}

This file is the access policy, written once and readable by a person. Every route that points at one object has a row with a short label, a concrete URL to call (always Alice’s data), an optional request body, and the status the owner should get back. The three routes that do not point at one object, the list, the create, and the activity feed, are listed separately because their rules are different, and they get individual tests below.

Turn the rules into tests: test_authz_matrix.py

# test_authz_matrix.py
import importlib
import os

import pytest
from fastapi.testclient import TestClient

from matrix import COLLECTION_ROUTES, OBJECT_ROUTES

create_app = importlib.import_module(os.environ.get("LEDGER_APP", "app_v3")).create_app

ALICE = {"Authorization": "Bearer tok-alice"}
BOB = {"Authorization": "Bearer tok-bob"}
ACTORS = {"anonymous": {}, "owner": ALICE, "other": BOB}  # every object route is called on Alice's data


@pytest.fixture
def client(tmp_path):
    return TestClient(create_app(str(tmp_path / "ledger.db")))  # a fresh app and a fresh database per test


HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options", "trace"}


def routes_in_schema(app):
    paths = app.openapi()["paths"]
    return {(method.upper(), path) for path, item in paths.items() for method in item if method in HTTP_METHODS}


def test_every_route_is_declared(client):
    found = routes_in_schema(client.app)
    declared = set(OBJECT_ROUTES) | set(COLLECTION_ROUTES)
    assert found, "the schema lists no routes, so this inventory cannot see the app"
    assert not found - declared, f"routes with no access rule in matrix.py: {sorted(found - declared)}"
    assert not declared - found, f"rows in matrix.py that match no route: {sorted(declared - found)}"


CASES = [pytest.param(key, actor, id=f"{OBJECT_ROUTES[key][0]} as {actor}") for key in OBJECT_ROUTES for actor in ACTORS]


@pytest.mark.parametrize("key,actor", CASES)
def test_object_matrix(client, key, actor):
    method, _ = key
    _, path, body, owner_status = OBJECT_ROUTES[key]
    r = client.request(method, path, headers=ACTORS[actor], json=body)
    expected = {"owner": [owner_status], "anonymous": [401], "other": [403, 404]}[actor]
    assert r.status_code in expected, f"{method} {path} as {actor}: got {r.status_code}, expected {' or '.join(map(str, expected))}"


@pytest.mark.parametrize("key", list(OBJECT_ROUTES), ids=lambda k: OBJECT_ROUTES[k][0])
def test_foreign_looks_like_missing(client, key):
    method, _ = key
    label, path, body, _ = OBJECT_ROUTES[key]
    foreign = client.request(method, path, headers=BOB, json=body)
    missing = client.request(method, path.replace("/invoices/1", "/invoices/999"), headers=BOB, json=body)
    assert (foreign.status_code, foreign.text) == (missing.status_code, missing.text), (
        f"{label}: foreign answers {foreign.status_code}, missing answers {missing.status_code}"
    )


@pytest.mark.parametrize("key", list(COLLECTION_ROUTES), ids=lambda k: f"{k[0]} {k[1]}")
def test_anonymous_gets_401_on_collections(client, key):
    method, _ = key
    path, body = COLLECTION_ROUTES[key]
    assert client.request(method, path, json=body).status_code == 401


def test_list_only_returns_own_rows(client):
    rows = client.get("/invoices", headers=BOB).json()
    assert {r["owner"] for r in rows} == {"bob"}


def test_list_ignores_the_owner_filter(client):
    r = client.get("/invoices?owner=alice", headers=BOB)
    rows = r.json() if r.status_code == 200 else []
    assert all(row["owner"] == "bob" for row in rows), "Bob asked for ?owner=alice and received Alice's invoices"


def test_cannot_create_an_invoice_for_someone_else(client):
    r = client.post("/invoices", headers=BOB, json={"customer": "Fake LLC", "amount_cents": 1, "owner": "alice"})
    alices = client.get("/invoices", headers=ALICE).json()
    assert r.status_code == 422 and len(alices) == 3, f"POST with an owner field answered {r.status_code}; Alice now has {len(alices)} invoices"


def test_cannot_reassign_the_owner(client):
    r = client.patch("/invoices/4", headers=BOB, json={"owner": "alice"})
    assert r.status_code == 422, f"PATCH with an owner field answered {r.status_code}"


def test_activity_hides_other_owners(client):
    ids = {e["invoice_id"] for e in client.get("/activity", headers=ALICE).json()}
    assert ids <= {"1", "2", "3"}, f"Alice's activity feed names invoices that are not hers: {sorted(ids - {'1', '2', '3'})}"


def test_attachment_belongs_to_its_invoice(client):
    r = client.get("/invoices/4/attachments/1", headers=BOB)  # Bob's own invoice, Alice's attachment
    assert r.status_code == 404, f"Bob read Alice's attachment through his own invoice: status {r.status_code}"

The tests fall into five groups.

  • test_every_route_is_declared is an inventory check. It asks the app for its OpenAPI schema, the document FastAPI generates to describe every path and method it serves, and compares that list with the rows in matrix.py. A route with no row fails the test and is named in the message. A row that matches no route fails too, which catches typos and stale rules. The first assertion makes sure the inventory is not empty; the gotchas section explains why that line is there.
  • test_object_matrix runs every object route as three actors: anonymous (no token), owner (Alice calling her own invoice 1), and other (Bob calling Alice’s invoice 1). That is 4 routes times 3 actors, or 12 cases. Anonymous must get 401, the owner must get the status in the matrix, and the other user must be denied with 403 or 404.
  • test_foreign_looks_like_missing checks for the information leak from step 5. It asks Bob for Alice’s invoice 1 and for invoice 999, which does not exist, and requires the status code and the response body to be identical.
  • test_anonymous_gets_401_on_collections checks that the three collection routes also demand a login.
  • Six single-purpose tests cover the remaining holes from step 3: Bob’s list contains only his rows, the ?owner=alice filter does not work, he cannot create an invoice for Alice, he cannot reassign an invoice by sending an owner field, Alice’s activity feed names only her invoices, and an attachment that belongs to Alice cannot be read through Bob’s own invoice.

That adds up to 26 tests. Each test gets a fresh application and a fresh database through the client fixture, because the PATCH and DELETE cases change data. An environment variable named LEDGER_APP chooses which app module to test and defaults to app_v3. Run the suite against the first draft and then against the patched version. Use python -m pytest, not bare pytest, so the project folder is on the import path. The flags are -q for quiet output, --tb=no to hide tracebacks, and -rf to print one summary line per failure.

# macOS or Linux
LEDGER_APP=app_v1 python -m pytest -q --tb=no -rf

# Windows (PowerShell)
$env:LEDGER_APP = "app_v1"; python -m pytest -q --tb=no -rf
...F..F..F..FFFF.....FFFFF [100%]
=== short test summary info ===
FAILED test_authz_matrix.py::test_object_matrix[get invoice as other] - AssertionError: GET /invoices/1 as other: got 200, expected 403 or 404
FAILED test_authz_matrix.py::test_object_matrix[patch invoice as other] - AssertionError: PATCH /invoices/1 as other: got 200, expected 403 or 404
FAILED test_authz_matrix.py::test_object_matrix[delete invoice as other] - AssertionError: DELETE /invoices/1 as other: got 204, expected 403 or 404
FAILED test_authz_matrix.py::test_object_matrix[get attachment as other] - AssertionError: GET /invoices/1/attachments/1 as other: got 200, expected 403 or 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[get invoice] - AssertionError: get invoice: foreign answers 200, missing answers 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[patch invoice] - AssertionError: patch invoice: foreign answers 200, missing answers 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[delete invoice] - AssertionError: delete invoice: foreign answers 204, missing answers 404
FAILED test_authz_matrix.py::test_list_ignores_the_owner_filter - AssertionError: Bob asked for ?owner=alice and received Alice's invoices
FAILED test_authz_matrix.py::test_cannot_create_an_invoice_for_someone_else - AssertionError: POST with an owner field answered 201; Alice now has 4 invoices
FAILED test_authz_matrix.py::test_cannot_reassign_the_owner - AssertionError: PATCH with an owner field answered 200
FAILED test_authz_matrix.py::test_activity_hides_other_owners - AssertionError: Alice's activity feed names invoices that are not hers: ['4', '5', '6']
FAILED test_authz_matrix.py::test_attachment_belongs_to_its_invoice - AssertionError: Bob read Alice's attachment through his own invoice: status 200
12 failed, 14 passed in 1.95s

The first draft fails 12 tests. Every failure line says which rule broke and what the server answered, for example that Bob received a 200 where a 403 or 404 was expected. One result deserves an explanation: get attachment passes test_foreign_looks_like_missing on the first draft, but for the wrong reason. That route never looks at the invoice ID in the URL, so a missing invoice answers exactly like a foreign one, with the same 200 and the same attachment. Now the patched version, using the same command with app_v2:

# macOS or Linux
LEDGER_APP=app_v2 python -m pytest -q --tb=no -rf

# Windows (PowerShell)
$env:LEDGER_APP = "app_v2"; python -m pytest -q --tb=no -rf
......F..F...FFFF....FFFFF [100%]
=== short test summary info ===
FAILED test_authz_matrix.py::test_object_matrix[patch invoice as other] - AssertionError: PATCH /invoices/1 as other: got 200, expected 403 or 404
FAILED test_authz_matrix.py::test_object_matrix[delete invoice as other] - AssertionError: DELETE /invoices/1 as other: got 204, expected 403 or 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[get invoice] - AssertionError: get invoice: foreign answers 403, missing answers 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[patch invoice] - AssertionError: patch invoice: foreign answers 200, missing answers 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[delete invoice] - AssertionError: delete invoice: foreign answers 204, missing answers 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[get attachment] - AssertionError: get attachment: foreign answers 403, missing answers 404
FAILED test_authz_matrix.py::test_list_ignores_the_owner_filter - AssertionError: Bob asked for ?owner=alice and received Alice's invoices
FAILED test_authz_matrix.py::test_cannot_create_an_invoice_for_someone_else - AssertionError: POST with an owner field answered 201; Alice now has 4 invoices
FAILED test_authz_matrix.py::test_cannot_reassign_the_owner - AssertionError: PATCH with an owner field answered 200
FAILED test_authz_matrix.py::test_activity_hides_other_owners - AssertionError: Alice's activity feed names invoices that are not hers: ['4', '5', '6']
FAILED test_authz_matrix.py::test_attachment_belongs_to_its_invoice - AssertionError: Bob read Alice's attachment through his own invoice: status 200
11 failed, 15 passed in 1.92s

The patch moved the count from 12 failures to 11. The two as other failures for the edit and delete routes are still there, the 403 is now flagged by test_foreign_looks_like_missing for the invoice and attachment routes, and five of the six single-purpose tests still fail. A reviewer reading the diff for app_v2.py would have approved it. The matrix shows how little it actually fixed.

Step 7: Fix it structurally

The fix has one central idea: make the unsafe query hard to write by accident. Instead of asking every route to remember an ownership check, give routes only an object whose every query already includes the owner. OWASP’s IDOR cheat sheet describes the same move: “When looking up objects based on primary keys, use datasets that users have access to.” The Top 10:2025 page asks for the same thing at the design level: “Model access controls should enforce record ownership rather than allowing users to create, read, update, or delete any record.”

Move 1: put the owner in the query

Create repo.py. Its InvoiceRepo class is a repository, an object that wraps database access, and each instance is tied to one owner:

# repo.py
EDITABLE = {"customer", "amount_cents", "note", "status"}


class InvoiceRepo:
    """Every query in this class is tied to one owner. It has no method that can see anyone else's rows."""

    def __init__(self, store, owner):
        self.store = store
        self.db = store.db
        self.owner = owner

    def _row(self, sql, args=()):
        row = self.db.execute(sql, args).fetchone()
        return dict(row) if row else None

    def list(self):
        rows = self.db.execute("SELECT * FROM invoices WHERE owner = ? ORDER BY rowid", (self.owner,))
        return [dict(r) for r in rows]

    def get(self, invoice_id):
        return self._row("SELECT * FROM invoices WHERE id = ? AND owner = ?", (invoice_id, self.owner))

    def create(self, customer, amount_cents, note=""):
        return self.store.create(self.owner, customer, amount_cents, note)  # the owner is never a parameter

    def update(self, invoice_id, fields):
        unknown = set(fields) - EDITABLE
        if unknown:
            raise ValueError(f"not editable: {sorted(unknown)}")
        if fields:
            sets = ", ".join(f"{name} = ?" for name in fields)  # names come from the EDITABLE allowlist
            sql = f"UPDATE invoices SET {sets} WHERE id = ? AND owner = ?"
            self.db.execute(sql, (*fields.values(), invoice_id, self.owner))
            self.db.commit()
        return self.get(invoice_id)

    def delete(self, invoice_id):
        if self.get(invoice_id) is None:
            return False
        self.db.execute(
            "DELETE FROM attachments WHERE invoice_id IN (SELECT id FROM invoices WHERE id = ? AND owner = ?)",
            (invoice_id, self.owner),
        )
        self.db.execute("DELETE FROM invoices WHERE id = ? AND owner = ?", (invoice_id, self.owner))
        self.db.commit()
        return True

    def attachment(self, invoice_id, attachment_id):
        return self._row(
            "SELECT a.* FROM attachments a JOIN invoices i ON i.id = a.invoice_id "
            "WHERE a.id = ? AND a.invoice_id = ? AND i.owner = ?",
            (attachment_id, invoice_id, self.owner),
        )

    def activity(self, limit=5):
        rows = self.db.execute(
            "SELECT e.invoice_id, e.kind FROM events e JOIN invoices i ON i.id = e.invoice_id "
            "WHERE i.owner = ? ORDER BY e.seq DESC LIMIT ?",
            (self.owner, limit),
        )
        return [dict(r) for r in rows]

Look at every SQL statement in the file. Each one has owner = ? or, for the attachment and activity queries, a join to invoices with i.owner = ?, and the bound value is always self.owner. Apart from the constructor, no method takes an owner as an argument, so no route can ask the repository for somebody else’s rows. This also repairs the nested route: attachment() requires the attachment ID, the invoice ID, and the owner to match in one query, so a child can no longer be reached through a parent that is not its own. When a row is not the caller’s, the query returns nothing, and for the caller that is the same thing as the row not existing.

Moves 2 to 5: build the routes on top of it

Now create app_v3.py:

# app_v3.py - LedgerBox v3: every query is scoped to the caller
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, ConfigDict

from auth import current_user
from repo import InvoiceRepo
from store import Store

router = APIRouter(dependencies=[Depends(current_user)])  # deny by default: nothing on this router is anonymous


def get_repo(request: Request, user: str = Depends(current_user)) -> InvoiceRepo:
    return InvoiceRepo(request.app.state.store, user)


def not_found() -> HTTPException:
    return HTTPException(status_code=404, detail="not found")  # one answer for "missing" and "not yours"


class InvoiceIn(BaseModel):
    model_config = ConfigDict(extra="forbid")  # an owner field is rejected with 422 instead of being ignored
    customer: str
    amount_cents: int
    note: str = ""


class InvoicePatch(BaseModel):
    model_config = ConfigDict(extra="forbid")
    customer: str | None = None
    amount_cents: int | None = None
    note: str | None = None
    status: str | None = None


@router.get("/invoices")
async def list_invoices(repo: InvoiceRepo = Depends(get_repo)):
    return repo.list()


@router.post("/invoices", status_code=201)
async def create_invoice(body: InvoiceIn, repo: InvoiceRepo = Depends(get_repo)):
    return repo.create(body.customer, body.amount_cents, body.note)


@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, repo: InvoiceRepo = Depends(get_repo)):
    inv = repo.get(invoice_id)
    if inv is None:
        raise not_found()
    return inv


@router.patch("/invoices/{invoice_id}")
async def patch_invoice(invoice_id: str, body: InvoicePatch, repo: InvoiceRepo = Depends(get_repo)):
    inv = repo.update(invoice_id, body.model_dump(exclude_unset=True))
    if inv is None:
        raise not_found()
    return inv


@router.delete("/invoices/{invoice_id}", status_code=204)
async def delete_invoice(invoice_id: str, repo: InvoiceRepo = Depends(get_repo)):
    if not repo.delete(invoice_id):
        raise not_found()


@router.get("/invoices/{invoice_id}/attachments/{attachment_id}")
async def get_attachment(invoice_id: str, attachment_id: str, repo: InvoiceRepo = Depends(get_repo)):
    att = repo.attachment(invoice_id, attachment_id)
    if att is None:
        raise not_found()
    return att


@router.get("/activity")
async def activity(repo: InvoiceRepo = Depends(get_repo)):
    return repo.activity()


def create_app(db_path: str, id_style: str = "int") -> FastAPI:
    app = FastAPI(title="LedgerBox v3")
    app.state.store = Store(db_path, id_style)
    app.include_router(router)
    return app

Four decisions are packed into this file, numbered to continue from the first move.

  1. Routes receive a scoped repository, not the raw database. get_repo builds an InvoiceRepo for the current user and every route asks for it with Depends(get_repo). A route that uses the repository cannot forget the owner check, because the check is not in the route. Nothing in Python stops a route from bypassing the repository, which is exactly what the export example in step 8 does, and that is why the tests exist. OWASP’s Top 10:2025 page recommends exactly this shape: “Implement access control mechanisms once and reuse them throughout the application.”
  2. One answer for “missing” and “not yours”. not_found() is the only error the object routes raise themselves, and it carries the same status and the same body every time. The caller cannot tell the two situations apart, so the 403 map from step 5 disappears.
  3. Request bodies cannot carry an owner. The owner field is gone from both models, and ConfigDict(extra="forbid") makes pydantic reject unknown fields. Pydantic’s documentation describes the three settings: 'ignore' means “Providing extra data is ignored (the default).” and 'forbid' means “Providing extra data is not permitted.” Ignoring would also be safe here, since the repository never reads an owner from the client, but a 422 error makes an attempted takeover visible instead of silent.
  4. Deny by default at the router. APIRouter(dependencies=[Depends(current_user)]) attaches the login requirement to every route on that router, a feature FastAPI’s bigger applications tutorial shows. A route someone adds to this router later and forgets to protect still answers 401 to anonymous callers. The Top 10:2025 page puts the principle in one sentence: “Except for public resources, deny by default.”

Run the attack script against the new version:

python attack.py v3
--- reading ---
a foreign invoice         GET    /invoices/4                   404  {"detail":"not found"}
a missing invoice         GET    /invoices/99                  404  {"detail":"not found"}
the owner filter          GET    /invoices?owner=bob           200  [{"id":"1","owner":"alice","customer":"Acme Corp","amount_cent
a foreign attachment      GET    /invoices/1/attachments/3     404  {"detail":"not found"}
the activity feed         GET    /activity                     200  [{"invoice_id":"3","kind":"created"},{"invoice_id":"2","kind":
ids 1-12 that are not Alice's but did not answer 404: []
--- writing ---
edit a foreign invoice    PATCH  /invoices/4                   404  {"detail":"not found"}
take over an invoice      PATCH  /invoices/6                   422  {"detail":[{"type":"extra_forbidden","loc":["body","owner"],"m
delete a foreign invoice  DELETE /invoices/5                   404  {"detail":"not found"}
plant an invoice for Bob  POST   /invoices                     422  {"detail":[{"type":"extra_forbidden","loc":["body","owner"],"m

Compare each line with the first run. The foreign invoice and the missing invoice now give the identical answer, 404 with {"detail":"not found"}. The owner filter is no longer honored: the response is Alice’s own list. The foreign attachment, the edit, and the delete all answer 404. The two attempts to name an owner in a request body stop at validation with a 422 error, whose body names the unexpected owner field. The activity feed shows only Alice’s own events, and the sweep for IDs that did not answer 404 comes back empty: []. From these probes Alice learns nothing about Bob’s data, including how much of it exists.

Here is the whole picture side by side, taken from the three runs:

What Alice tries First draft Patched Scoped
Read Bob’s invoice 4 200 403 404
Read a missing invoice 99 404 404 404
List with ?owner=bob Bob’s rows Bob’s rows her own rows
Read Bob’s attachment through her invoice 1 200 200 404
Activity feed all customers all customers her events only
Edit Bob’s invoice 4 200 200 404
Take over invoice 6 with an owner field 200 200 422
Delete Bob’s invoice 5 204 204 404
Plant an invoice for Bob 201 201 422

The matrix gives the verdict. This is the default run, so you do not need to set the environment variable:

python -m pytest -q
.......................... [100%]
26 passed in 2.32s

All 26 tests pass. The same suite that failed 12 and 11 tests against the earlier versions passes against the fixed one without a single change to the tests: they describe the rule, and the code now satisfies it.

Step 8: Keep it fixed when the code grows

A fix that holds today can be undone by next quarter’s feature. The Authorization Testing Automation cheat sheet names the usual cause: authorization problems appear when features are added in later releases without checking their effect on the rules. To see how the suite handles that, play the part of a well-meaning developer who adds an invoice export endpoint. Create app_v3_export.py:

# app_v3_export.py - what a well-meaning developer adds six months later
from fastapi import Depends, HTTPException

import app_v3
from auth import current_user


def create_app(db_path: str, id_style: str = "int"):
    app = app_v3.create_app(db_path, id_style)
    store = app.state.store

    @app.get("/invoices/{invoice_id}/export")
    async def export_invoice(invoice_id: str, user: str = Depends(current_user)):
        inv = store.invoice(invoice_id)  # the old unscoped lookup is still lying around
        if inv is None:
            raise HTTPException(status_code=404, detail="not found")
        return {"filename": f"invoice-{inv['id']}.csv", "csv": f"{inv['customer']},{inv['amount_cents']}"}

    return app

This route works and it even requires a login, but it adds itself to the app instead of the protected router, and it looks the invoice up with the old unscoped store.invoice() method, which still exists. Run the suite against it:

# macOS or Linux
LEDGER_APP=app_v3_export python -m pytest -q --tb=no -rf

# Windows (PowerShell)
$env:LEDGER_APP = "app_v3_export"; python -m pytest -q --tb=no -rf
F......................... [100%]
=== short test summary info ===
FAILED test_authz_matrix.py::test_every_route_is_declared - AssertionError: routes with no access rule in matrix.py: [('GET', '/invoices/{invoice_id}/export')]
1 failed, 25 passed in 1.87s

The build fails before anyone has to think about authorization: the new route has no access rule in matrix.py, and the failure message names it. The developer now has to decide what the rule is. For an object route the rule is always the same, so they add one row to OBJECT_ROUTES in matrix.py:

# matrix.py (add this line inside OBJECT_ROUTES, after the attachments row)
    ("GET", "/invoices/{invoice_id}/export"): ("export invoice", "/invoices/1/export", None, 200),

Run the same command again:

...............F....F......... [100%]
=== short test summary info ===
FAILED test_authz_matrix.py::test_object_matrix[export invoice as other] - AssertionError: GET /invoices/1/export as other: got 200, expected 403 or 404
FAILED test_authz_matrix.py::test_foreign_looks_like_missing[export invoice] - AssertionError: export invoice: foreign answers 200, missing answers 404
2 failed, 28 passed in 2.85s

Now the matrix does its job. Bob received a 200 for Alice’s export, and the missing-versus-foreign check fails too. The route is declared and still unsafe, and the build stays red until it is fixed. The fix is to write the route against the repository, like every other route. Create app_v3_export_fixed.py:

# app_v3_export_fixed.py - the same endpoint, written against the scoped repository
from fastapi import Depends

import app_v3
from app_v3 import InvoiceRepo, get_repo, not_found


def create_app(db_path: str, id_style: str = "int"):
    app = app_v3.create_app(db_path, id_style)

    @app.get("/invoices/{invoice_id}/export")
    async def export_invoice(invoice_id: str, repo: InvoiceRepo = Depends(get_repo)):
        inv = repo.get(invoice_id)
        if inv is None:
            raise not_found()
        return {"filename": f"invoice-{inv['id']}.csv", "csv": f"{inv['customer']},{inv['amount_cents']}"}

    return app
# macOS or Linux
LEDGER_APP=app_v3_export_fixed python -m pytest -q --tb=no -rf

# Windows (PowerShell)
$env:LEDGER_APP = "app_v3_export_fixed"; python -m pytest -q --tb=no -rf
.............................. [100%]
30 passed in 2.19s

All 30 tests pass: the original 26 plus the four new ones for the export route (three actors and the foreign-versus-missing check). The unsafe version worked only because the old Store.invoice() method was still lying around. Once nothing legitimate calls the unscoped methods, delete them. A footgun that is not there cannot be picked up. Then run these tests in your CI pipeline on every pull request, so that the rule “do not deploy changes that make the tests fail” is enforced by a machine.

Common mistakes and gotchas

Comparing the token’s user ID with the ID in the URL is not enough

A common first instinct is a check like “the user ID in the JWT must equal the {user_id} in the path.” OWASP rejects it in so many words: “Comparing the user ID of the current session (e.g. by extracting it from the JWT token) with the vulnerable ID parameter isn’t a sufficient solution to solve Broken Object Level Authorization (BOLA).” OWASP adds that this approach “could address only a small subset of cases.” It fits routes shaped like /users/{id}. Invoices, files, orders, and tickets are not users; their ownership is a relationship stored in your data, which is why the ownership predicate belongs in the query.

Authorize the whole chain, not just the parent

The patched attachment route was the textbook example. Checking the invoice in the URL and loading the attachment by its own ID leaves the two unconnected. Whenever a route has two or more IDs, one query should tie them together and to the owner, as InvoiceRepo.attachment does.

403 and 404 are not interchangeable

Returning 403 for other people’s objects and 404 for missing ones tells any caller which IDs exist, which is how the sweep in step 5 found invoices 4 to 6. Either status is acceptable as long as it is the same status and the same body for both cases. This tutorial uses 404, because a caller has no business knowing whether somebody else’s object exists. The test test_foreign_looks_like_missing keeps that decision from eroding.

Every input is an input, not only the path

The first draft’s holes were in a query string (owner), request bodies (owner again), and a feed that took no input at all. The IDOR cheat sheet asks for verification “for all operations involving object references, including read, create, update, delete, export, and administrative actions.” The Top 10:2025 page lists “An accessible API with missing access controls for POST, PUT, and DELETE” among the common failures. Reads are the easy case to remember. Writes are where records get changed, deleted, and reassigned.

Roles are a separate question

BOLA asks whether this user may touch this object. A different category, broken function level authorization, asks whether this user may call this function at all, for example an admin-only endpoint. OWASP separates them: reaching an endpoint you should not be able to reach “is a case of Broken Function Level Authorization (BFLA) rather than BOLA.” If your API has roles, add an actor for each role to the matrix. The same table scales to it.

An inventory that cannot see your routes passes forever

My first version of test_every_route_is_declared walked app.routes and kept the APIRoute objects. It passed on the fixed app, and I almost moved on. Then I printed what it had found: zero routes. In FastAPI 0.142.4, app.include_router(router) adds the router to app.routes as one private entry instead of copying its routes in, so a walk over app.routes sees only the routes registered directly on the app. On the export variant from step 8 it found exactly one route, the one added with @app.get, and nothing else. A test that cannot fail is not a test, so the final version reads the OpenAPI schema, which is public and lists the app’s routes, and asserts that the list is not empty. The second assertion, rows with no matching route, does extra work here: it can only pass if the inventory really sees the routes that live on the router. One limit remains. Routes registered with include_in_schema=False are left out of the schema, so avoid that option in this project or inventory those routes some other way.

One object proves little

The matrix tests Alice’s invoice 1 against Bob. That catches the missing check and not every special case. Real data has shared objects, archived objects, objects in other tenants, and objects whose owner changed. When your product grows a sharing feature, the repository’s owner = ? becomes a join to a permissions table, and the matrix grows an actor with shared access. The Authorization cheat sheet has a section headed “Prefer Attribute and Relationship Based Access Control over RBAC” that is worth reading at that point.

Watch the denials

An attacker who walks through IDs produces a burst of 404 answers for one user. Because the scoped design deliberately gives the same answer for “missing” and “not yours”, that burst is the signal, and it is worth logging. The Top 10:2025 page recommends to “Log access control failures, alert admins when appropriate (e.g., repeated failures)” and to “Implement rate limits on API and controller access to minimize the harm from automated attack tooling.” Neither replaces the ownership check, and both make the checks you missed easier to notice.

Confirm that everything works end to end

Your project folder should contain these files: store.py, auth.py, labserver.py, app_v1.py, attack.py, uuid_leak.py, app_v2.py, matrix.py, test_authz_matrix.py, repo.py, app_v3.py, app_v3_export.py, and app_v3_export_fixed.py. With them in place, four checks tell you the whole story:

  1. python attack.py v3 never shows Bob’s data: the foreign invoice, attachment, edit, and delete answer 404, the two request-body probes answer 422, the list and the feed contain only Alice’s rows, and the sweep prints an empty list.
  2. python -m pytest -q prints 26 passed.
  3. The same suite with LEDGER_APP=app_v1 reports 12 failures and with LEDGER_APP=app_v2 reports 11. If it passes on the vulnerable versions, the tests are not testing anything.
  4. Adding a route without a row in matrix.py fails test_every_route_is_declared and names the route.

Where to go from here

Move the lessons into your own API in three moves. Write the matrix for your real routes first, and expect it to fail somewhere, because that is its job. Then push the owner into your data access layer so that routes written against it cannot see other tenants’ rows. Finally, run the matrix in CI. OWASP’s Authorization Testing Automation cheat sheet shows another way to store the matrix as a data file and render it for audits.

If you want to keep going with the same style of hands-on security tutorial, these earlier ones pair well with this one: preventing SQL injection with parameterized queries, stopping XSS with a Content Security Policy, stopping CSRF with synchronizer tokens, preventing server-side request forgery, preventing path traversal in file downloads, and verifying webhook signatures. For the same question asked about AI agents, who may act with whose authority, see fixing the confused deputy problem with scoped authorization tokens.

Sources

  • OWASP API Security Top 10, API1:2023 Broken Object Level Authorization
  • OWASP Top 10:2025, A01 Broken Access Control
  • OWASP Authorization Cheat Sheet
  • OWASP Insecure Direct Object Reference Prevention Cheat Sheet
  • OWASP Authorization Testing Automation Cheat Sheet
  • CWE-639: Authorization Bypass Through User-Controlled Key
  • RFC 9562, Universally Unique IDentifiers (UUIDs), section 8 Security Considerations
  • Pydantic documentation, Models (extra data)
  • FastAPI documentation, Bigger Applications (router dependencies)

Tags:

Access ControlAPI SecurityApplication SecurityFastAPIOWASPPython

Share

Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
Previous Post

Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating

A lugworm lying on wet sand and mud at low tide
Next Post

A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A customer-support representative wearing a headset against a dark studio background.
Articles

The Meta AI Support Hack Was a Plain Old Authorization Failure

June 7, 2026
A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026