Section 1: Thinking about risk as a developer
Broken access control: check on the server, every time
Broken access control means users can act outside their intended permissions: reading another customer's invoice, changing someone else's settings, or reaching admin functions as a normal user. It has topped the OWASP list for years because it is easy to get wrong and frameworks cannot fully prevent it, since only your code knows the business rules.
The typical mistake
@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
return db.get(Invoice, invoice_id) # any logged in user can read any invoice
The user is authenticated, but nobody checked whether this user may see this invoice. Hiding the link in the interface does not help: the server must decide.
The fix: scope queries to the user
@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
invoice = db.scalar(
select(Invoice).where(Invoice.id == invoice_id,
Invoice.account_id == user.account_id)
)
if invoice is None:
raise HTTPException(status_code=404)
return invoice
Filtering by the user's account in the query itself means there is no code path that loads someone else's record. Returning 404 rather than 403 avoids confirming that the record exists.
Principles that prevent most problems
- Deny by default. Every route requires an explicit permission decision; new routes start closed.
- Enforce on the server, never only in the user interface or the mobile app.
- Centralise checks in one authorisation layer or policy library, instead of scattering ad hoc
ifstatements through handlers. - Check the object, not just the role. "Is an editor" is not enough; ask "may edit this document".
- Do not trust identifiers from the client for ownership. Take the user and tenant from the authenticated session, not from a request field.
- Protect every method. A route that checks permissions on GET but not on PUT or DELETE is a common gap.
Tests that keep it fixed
Write tests for the negative cases, which are the ones most often missing:
def test_user_cannot_read_other_accounts_invoice(client, alice, bobs_invoice):
r = client.get(f"/invoices/{bobs_invoice.id}", headers=alice.auth)
assert r.status_code == 404
A small set of "user A cannot touch user B's data" tests for each resource type catches regressions that code review easily misses.