Cybersecurity for Developers
Hands-onIntermediate 35 min· Secure coding · 5 of 13

Authorisation and broken access control

Reproduce an IDOR bug in a small orders API on your own machine, fix it with object-level and role checks, and lock the fix in with tests, so a customer can never read another customer's order.

In this tutorial you will fix the number one item on the OWASP Top 10: broken access control. You will take a small Brew & Bean orders API, see how a logged-in customer can read another customer's order, then fix it and write tests that keep it fixed.

Prerequisites: Python 3.10 or newer, basic FastAPI knowledge (routes and dependencies) and the idea of authentication from *Passwords, authentication and MFA done right*. This is a practice app on your own machine with fake data and fake tokens. Never try these checks on a site you do not own or have permission to test.

Authentication versus authorisation

Authentication is proving who you are (logging in). Authorisation is deciding what that person may do. Most access control bugs happen when the app does the first and forgets the second: "the user is logged in, so let them through".

There are two kinds of check you must do on the server for every request:

Function-level: may this role call this endpoint at all? (Only staff may change an order's status.)

Object-level: may this user touch this particular record? (A customer may read only their own orders.)

The classic bugs

Common access control failures

BugWhat it looks likeFix
IDOR (insecure direct object reference)Change /orders/41 to /orders/42 and see someone else's orderCheck ownership of the object in every handler
Missing function-level checkA customer calls PATCH /orders/41/status and it worksRequire a role on the endpoint
Trusting the clientRequest body contains "role": "admin" or "user_id": 2 and the server believes itTake identity and role from the verified session or token, never from the body
Hidden UI onlyThe Admin button is hidden in React, but the API is openHiding a button is not security; enforce on the API
Mass assignmentPATCH /me accepts any field, including is_staffAccept only an allow-list of fields
Forced browsing/admin/export has no check because 'nobody knows the URL'Authorise every route; deny by default

Step 1: set up the practice app

Setup

  1. 1Create a folder access-lab and open a terminal there.
  2. 2Create and activate a virtual environment: python -m venv .venv, then activate it (Git Bash: source .venv/Scripts/activate, macOS/Linux: source .venv/bin/activate, PowerShell: .venv\Scripts\Activate.ps1).
  3. 3Install the packages: pip install fastapi uvicorn httpx pytest.
  4. 4Save the code in the next block as app.py.

The app below has two orders that belong to two different customers, three demo logins (the 'tokens' stand in for real authentication) and two versions of the read-order route: an unsafe one under /unsafe/orders/{id} so you can see the bug, and the fixed one under /orders/{id}. In your own projects you would never keep the unsafe route; it is here only to show the difference side by side.

app.py
from fastapi import FastAPI, Depends, HTTPException, Header

app = FastAPI()

# Demo data and logins (in a real app: a database and real authentication)
TOKENS = {
    "token-asha": {"id": 1, "role": "customer"},
    "token-ravi": {"id": 2, "role": "customer"},
    "token-staff": {"id": 99, "role": "staff"},
}
ORDERS = {
    41: {"id": 41, "user_id": 1, "phone": "9876500001", "status": "received", "total": 330},
    42: {"id": 42, "user_id": 2, "phone": "9876500002", "status": "ready", "total": 180},
}
STATUSES = {"received", "preparing", "ready", "picked_up", "cancelled"}


def current_user(authorization: str = Header(default="")):
    token = authorization.removeprefix("Bearer ").strip()
    user = TOKENS.get(token)
    if user is None:
        raise HTTPException(status_code=401, detail="Not logged in")
    return user


def require_role(*roles: str):
    def checker(user: dict = Depends(current_user)):
        if user["role"] not in roles:
            raise HTTPException(status_code=403, detail="Not allowed")
        return user
    return checker


# ---- VULNERABLE: any logged-in user can read any order ----
@app.get("/unsafe/orders/{order_id}")
def get_order_unsafe(order_id: int, user: dict = Depends(current_user)):
    order = ORDERS.get(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")
    return order


# ---- FIXED: owner or staff only ----
@app.get("/orders/{order_id}")
def get_order(order_id: int, user: dict = Depends(current_user)):
    order = ORDERS.get(order_id)
    if order is None or (user["role"] != "staff" and order["user_id"] != user["id"]):
        # Same answer for "missing" and "not yours": do not reveal which ids exist
        raise HTTPException(status_code=404, detail="Order not found")
    return order


@app.get("/orders")
def list_my_orders(user: dict = Depends(current_user)):
    if user["role"] == "staff":
        return list(ORDERS.values())
    return [o for o in ORDERS.values() if o["user_id"] == user["id"]]


@app.patch("/orders/{order_id}/status")
def update_status(order_id: int, body: dict, user: dict = Depends(require_role("staff"))):
    order = ORDERS.get(order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")
    new_status = body.get("status")
    if new_status not in STATUSES:
        raise HTTPException(status_code=422, detail="Unknown status")
    order["status"] = new_status
    return order

Key parts: current_user reads the demo token and returns the user (that is authentication). require_role builds a dependency that returns 403 unless the role matches (function-level check). get_order checks that the order belongs to the user (object-level check).

Step 2: see the bug

Reproduce it on your own machine

  1. 1Start the server: uvicorn app:app --reload.
  2. 2In a second terminal, call the unsafe route as Asha (user 1) asking for Ravi's order 42.
  3. 3Notice that it works. Asha can read Ravi's phone number.
Terminal
curl -H "Authorization: Bearer token-asha" http://127.0.0.1:8000/unsafe/orders/42

On Windows PowerShell use curl.exe instead of curl, which is an alias for something else.

Unsafe route: Asha reads Ravi's order
{"id":42,"user_id":2,"phone":"9876500002","status":"ready","total":180}

Asha was authenticated, so the app let her in, but the app never asked whether order 42 is hers. Sequential ids make this easy to find, but even random ids would not fix the problem. The missing check is the bug; the id format only affects how hard it is to guess.

Step 3: apply the fix

Now call the fixed route with the same token.

Terminal
curl -i -H "Authorization: Bearer token-asha" http://127.0.0.1:8000/orders/42
curl -i -H "Authorization: Bearer token-asha" http://127.0.0.1:8000/orders/41
curl -i -H "Authorization: Bearer token-staff" http://127.0.0.1:8000/orders/42

The first returns 404, the second 200 with Asha's own order, the third 200 because staff may read any order.

Look at the fixed handler. The condition is: staff always pass; otherwise the order's user_id must equal the logged-in user's id. If not, the response is 404, the same as for an id that does not exist. Returning 403 would confirm that the order exists, which helps someone mapping your data. Many teams choose 404 for 'not yours' on private objects. Either is acceptable if you are consistent; the important part is that no data is returned.

In a database-backed app, the best habit is to put the ownership check into the query, so unauthorised rows are never loaded. With SQLAlchemy that looks like filtering by both the id and the owner.

Ownership inside the query
# SQLAlchemy 2.x style sketch
from sqlalchemy import select

stmt = select(Order).where(Order.id == order_id, Order.user_id == user.id)
order = session.execute(stmt).scalar_one_or_none()
if order is None:
    raise HTTPException(status_code=404, detail="Order not found")

If the order belongs to someone else, the query returns nothing, so the handler behaves exactly as if the order did not exist. Staff can use a different query without the owner filter.

Step 4: function-level checks

The status endpoint PATCH /orders/{id}/status is for staff only. It uses Depends(require_role("staff")), so a customer gets 403 Forbidden before the handler body runs. Compare the two failure codes: 401 means 'we do not know who you are' (not logged in); 403 means 'we know who you are, and you may not do this'.

Terminal
curl -i -X PATCH -H "Authorization: Bearer token-asha" -H "Content-Type: application/json" -d "{\"status\":\"picked_up\"}" http://127.0.0.1:8000/orders/41/status

curl -i -X PATCH -H "Authorization: Bearer token-staff" -H "Content-Type: application/json" -d "{\"status\":\"preparing\"}" http://127.0.0.1:8000/orders/41/status

First call: 403 with Not allowed. Second call: 200 and the updated order. (In PowerShell, the quoting of the JSON is awkward; use /docs in the browser at http://127.0.0.1:8000/docs with the Authorize header or write the calls as tests as in the next step.)

Deny by default

Make 'deny by default' structural. Put authorisation in dependencies, decorators or middleware that every route must use, instead of relying on each developer remembering to write an if. A route with no role declared should fail a test or code review.

Step 5: lock it in with tests

Access control bugs come back when code changes. Write tests that act as different users and assert the forbidden cases, not just the happy path. Save this as test_access.py next to app.py and run pytest -q.

test_access.py
from fastapi.testclient import TestClient
from app import app

client = TestClient(app)


def auth(token):
    return {"Authorization": f"Bearer {token}"}


def test_anonymous_gets_401():
    assert client.get("/orders/41").status_code == 401


def test_owner_can_read_own_order():
    assert client.get("/orders/41", headers=auth("token-asha")).status_code == 200


def test_customer_cannot_read_someone_elses_order():
    assert client.get("/orders/42", headers=auth("token-asha")).status_code == 404


def test_staff_can_read_any_order():
    assert client.get("/orders/42", headers=auth("token-staff")).status_code == 200


def test_customer_cannot_change_status():
    r = client.patch("/orders/41/status", json={"status": "picked_up"}, headers=auth("token-asha"))
    assert r.status_code == 403


def test_staff_can_change_status():
    r = client.patch("/orders/41/status", json={"status": "preparing"}, headers=auth("token-staff"))
    assert r.status_code == 200

Six tests: anonymous gets 401, owner gets 200, other customers get 404, staff can read, customers cannot change status, staff can. Run with pytest -q and expect 6 passed.

pytest output
......                                                       [100%]
6 passed

Step 6: design rules that prevent most bugs

Access control checklist

  1. Every endpoint requires authentication unless it is deliberately public (such as GET /menu).
  2. Every endpoint that touches one user's data checks ownership on the server.
  3. Roles come from the verified session or token, never from request fields or the frontend.
  4. Only an allow-list of fields can be updated; role, user_id and total are never client-settable.
  5. Use unpredictable ids (UUIDs) as defence in depth, but never as the only protection.
  6. Log denied attempts and alert on bursts, which can reveal someone probing ids.
  7. Test with at least two ordinary users and one admin.
Preventing mass assignment
from pydantic import BaseModel


class ProfileUpdate(BaseModel):
    # Allow-list: only these fields can ever be changed through this endpoint
    name: str | None = None
    phone: str | None = None
    # No 'role', 'is_staff' or 'user_id' here, so a client cannot send them

Pydantic models ignore extra fields by default, so a request containing "role": "admin" cannot change a role through this schema. Check this in your own framework: some ORMs and frameworks bind every incoming field automatically.

The UI is not a security boundary

Client-side checks (hiding buttons in React, disabling fields, checking a role in the browser) improve the user experience only. Anyone can call your API directly with curl or a script. The server must make every decision.

Prompt · Find missing authorisation
You are an application security reviewer. Review these API routes for broken access control: missing authentication, missing role checks (function level), missing ownership checks (object level), mass assignment, and trust in client-supplied ids or roles. For each finding give the route, why it is a problem, and a fixed version of the code. Then suggest pytest tests that log in as different users and check the forbidden cases.

Framework: [e.g. FastAPI / Express]

Routes:
[paste your routes here]

What to expect: A good answer lists routes by name, separates function-level from object-level problems and includes a test for each. Run the tests it writes against your own app. Do not paste secrets or real customer data.

How this comes up in interviews

Expect: "What is IDOR and how do you prevent it?" (an object reference such as an id is accepted without checking the user may access it; check ownership on the server for every request). "401 versus 403?" "Where should authorisation live?" (on the server, centrally, deny by default). "Role-based access control (RBAC) versus attribute-based (ABAC)?" RBAC grants permissions by role (customer, staff, admin). ABAC decides using attributes of the user, resource and context, for example 'staff may edit orders only for their own shop branch'. Many apps start with RBAC and add ownership rules.

Try it: Add a branch rule

The coffee shop opens a second branch. Add a branch field to each order and to staff users. A staff member should only read and update orders of their own branch, and an admin role should see everything. Write tests first: staff of branch A cannot read a branch B order (expect 404), admin can read both.

Key takeaways

  1. Authentication says who you are; authorisation says what you may do. Do both on the server for every request.
  2. Check both function level (role) and object level (ownership).
  3. Never take roles or user ids from the request body; use the verified identity.
  4. Put ownership checks inside database queries and deny by default.
  5. 401 means not logged in; 403 means not allowed; 404 can hide that a private object exists.
  6. Test the forbidden cases with different users, and remember that hiding UI is not security.

Check yourself

Finishing the quiz marks the lesson as done.

1. User A changes /orders/41 to /orders/42 and sees user B's order. What is this bug called?

2. A customer who is logged in calls a staff-only endpoint. Which status code fits best?

3. Why is hiding the 'Change status' button for customers not enough?

4. Which is the safest place to read the user's role from?

5. What does 'deny by default' mean?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free