Two questions that get answered in one place, badly
Authentication asks who you are. Authorisation asks whether you may do this, to this thing, right now. Most small applications answer the first carefully and the second by accident: a middleware that checks somebody is logged in, and then handlers that trust whatever ID arrives in the URL.
That gap has a name in the security literature (broken object-level authorisation) and it is consistently reported as the most common serious flaw in web APIs. It is also the easiest to demonstrate and the least likely to be caught by a test suite, because the request is perfectly valid.
GET /api/invoices/1041 -> your invoice
GET /api/invoices/1042 -> somebody else's invoice, 200 OKNo attack tool is required. Somebody changed a number.
The rule: authorise the object, not the route
A check at the router level answers “is this person allowed to use this feature”. It cannot answer “is this person allowed to touch this row”, because at routing time the row has not been fetched.
So the check belongs where the object is loaded, and the most reliable way to make that unavoidable is to put the ownership into the query itself:
-- not: SELECT * FROM invoices WHERE id = $1
SELECT * FROM invoices WHERE id = $1 AND account_id = $2;A missing row and a forbidden row now produce the same code path, which is both safer and simpler. The alternative (fetch, then compare, then decide) works, but every handler is a place to forget it, and handlers get copied.
The same reasoning applies to writes, to nested resources (/orders/12/items/98 needs both checks), to bulk endpoints (authorise every ID in the list, not the first one), and to anything that accepts an ID inside a JSON body where it is easier to overlook.
Guessable identifiers make it worse, and are not the fix
Sequential integer IDs let somebody enumerate your whole dataset with a loop. UUIDs make that impractical. But an unguessable ID is not an authorisation check: it is a secret that leaks through referrer headers, browser history, screenshots, shared links and support tickets. Use UUIDs for anything user-facing, and still check ownership. One is defence in depth; neither is sufficient alone.
Roles, and the thing roles cannot express
A role (admin, editor, viewer) answers a question about a person. Most real rules are about a person and an object: this user may edit posts in rooms they moderate, may read invoices for their own account, may delete a comment they wrote, within an hour.
Write those as a small number of named functions, in one file, and call them from every handler:
def can_edit_post(user, post):
return post.author_id == user.identity_id or user.moderates(post.room_id)Two benefits beyond correctness. The rules become readable by a person who is not the author, and they become testable without a browser: a table of user, object, expected answer, run in CI. A permissions test file is one of the highest-value tests a small application can have, because the failure it prevents is silent.
Fail closed, and be careful what the error reveals
Default deny: an unknown role, a missing field, an unhandled case must refuse. Code that reads if user.role == "banned": return 403 allows anyone whose role is an empty string.
On the response, there is a genuine trade-off. Returning 403 for a resource that exists tells the caller it exists, which is itself information, and for private objects it can matter. Returning 404 for both hides it, at the cost of a confusing message for a legitimate user who has lost access. Administrative surfaces in particular are usually better hidden entirely: an admin path that answers 404 to everyone unauthorised does not advertise that it is there.
Pick per surface, deliberately, and be consistent within it. The client needs to distinguish the cases it must act on (this course treats 401 as not signed in, 403 as signed in and refused, 428 as needing to agree to something first) and that only works if the codes are used honestly.
What to check on your own application this week
- Open a resource you own, note the ID, and request the neighbouring ID from a second account. Repeat for every resource type you have.
- Grep for
findById,get(id),where id =and read what happens next in each one. - Try the same on write and delete endpoints, and on any endpoint that takes an array of IDs.
- Check that a user removed from a team immediately loses access to that team’s objects: stale membership cached in a session is a common and unpleasant version of this bug.
Every one of those is fifteen minutes with a browser and a second account, and they find real holes in real applications with dispiriting regularity.
Every lesson here is open. Your progress is recorded once you pass the Agents and Automation exam.
Check your understanding
An API loads a record by UUID and returns it to any authenticated user, on the reasoning that UUIDs cannot be guessed. Which failure does this design permit?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Tri, your AI tutor
