{"course":{"slug":"cloud-and-shipping","title":"Shipping It","url":"https://addaly.com/learn/cloud-and-shipping","level":"intermediate","track":"build"},"lesson":{"slug":"who-is-allowed-to-do-what","title":"Authentication is not authorisation","url":"https://addaly.com/learn/cloud-and-shipping/who-is-allowed-to-do-what","position":58,"of":74,"minutes":9,"answer":"Being logged in is not permission to touch a particular row, so scope the query by the owner rather than checking the route, name each rule as a testable function, fail closed.","keyIdea":"Being logged in is not permission to touch a particular row, so scope the query by the owner rather than checking the route, name each rule as a testable function, fail closed, and remember that an unguessable ID is a secret rather than an authorisation check."},"body":{"format":"markdown","text":"## Two questions that get answered in one place, badly\n\nAuthentication 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.\n\nThat 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.\n\n```\nGET /api/invoices/1041     -> your invoice\nGET /api/invoices/1042     -> somebody else's invoice, 200 OK\n```\n\nNo attack tool is required. Somebody changed a number.\n\n## The rule: authorise the object, not the route\n\nA 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.\n\nSo 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:\n\n```sql\n-- not: SELECT * FROM invoices WHERE id = $1\nSELECT * FROM invoices WHERE id = $1 AND account_id = $2;\n```\n\nA 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.\n\nThe 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.\n\n## Guessable identifiers make it worse, and are not the fix\n\nSequential 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.\n\n## Roles, and the thing roles cannot express\n\nA 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*.\n\nWrite those as a small number of named functions, in one file, and call them from every handler:\n\n```python\ndef can_edit_post(user, post):\n    return post.author_id == user.identity_id or user.moderates(post.room_id)\n```\n\nTwo 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.\n\n## Fail closed, and be careful what the error reveals\n\nDefault 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.\n\nOn 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.\n\nPick 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.\n\n## What to check on your own application this week\n\n1. Open a resource you own, note the ID, and request the neighbouring ID from a second account. Repeat for every resource type you have.\n2. Grep for `findById`, `get(id)`, `where id =` and read what happens next in each one.\n3. Try the same on write and delete endpoints, and on any endpoint that takes an array of IDs.\n4. 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.\n\nEvery one of those is fifteen minutes with a browser and a second account, and they find real holes in real applications with dispiriting regularity."},"check":{"question":"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?","options":[{"text":"Attackers brute-forcing version-4 UUIDs across the keyspace","correct":false,"tempting":"Random UUIDs have far too much entropy for that to be practical; the risk is disclosure of a valid identifier, not guessing one."},{"text":"Any user who obtains a URL (from a shared link, a referrer header, a screenshot or a support ticket) gains permanent access to the object","correct":true},{"text":"UUID collisions returning another account's record","correct":false,"tempting":"Collision probability for version-4 UUIDs is negligible and is not a security consideration at any realistic scale."},{"text":"The database cannot index UUIDs efficiently, so the endpoint becomes slow under load","correct":false,"tempting":"A real performance consideration for random primary keys, but a performance point rather than the authorisation flaw in question."}]},"prev":{"slug":"the-fallback-when-the-model-is-down","title":"What the page says when the model is not there","url":"https://addaly.com/learn/cloud-and-shipping/the-fallback-when-the-model-is-down"},"next":{"slug":"sessions-and-logging-people-out","title":"Sessions, and taking one back","url":"https://addaly.com/learn/cloud-and-shipping/sessions-and-logging-people-out"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Authentication is not authorisation', in Shipping It. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/cloud-and-shipping/who-is-allowed-to-do-what (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Authentication is not authorisation","courseTitle":"Shipping It","module":{"index":7,"slug":"security-after-the-short-list","title":"Security after the short list"},"answer":null,"keyIdea":"Being logged in is not permission to touch a particular row, so scope the query by the owner rather than checking the route, name each rule as a testable function, fail closed, and remember that an unguessable ID is a secret rather than an authorisation check.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"two-questions-that-get-answered-in-one-place-badly","runs":[{"text":"Two questions that get answered in one place, badly"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"code","lang":"","label":"Text","text":"GET /api/invoices/1041     -> your invoice\nGET /api/invoices/1042     -> somebody else's invoice, 200 OK"},{"t":"p","runs":[{"text":"No attack tool is required. Somebody changed a number."}]},{"t":"h2","id":"the-rule-authorise-the-object-not-the-route","runs":[{"text":"The rule: authorise the object, not the route"}]},{"t":"p","runs":[{"text":"A check at the router level answers “is this person allowed to use this feature”. It cannot answer “is this person allowed to touch "},{"text":"this row","i":true},{"text":"”, because at routing time the row has not been fetched."}]},{"t":"p","runs":[{"text":"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:"}]},{"t":"code","lang":"sql","label":"SQL","text":"-- not: SELECT * FROM invoices WHERE id = $1\nSELECT * FROM invoices WHERE id = $1 AND account_id = $2;"},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"The same reasoning applies to writes, to nested resources ("},{"text":"/orders/12/items/98","code":true},{"text":" 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."}]},{"t":"h2","id":"guessable-identifiers-make-it-worse-and-are-not-the-fix","runs":[{"text":"Guessable identifiers make it worse, and are not the fix"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"roles-and-the-thing-roles-cannot-express","runs":[{"text":"Roles, and the thing roles cannot express"}]},{"t":"p","runs":[{"text":"A role (admin, editor, viewer) answers a question about a person. Most real rules are about a person "},{"text":"and","i":true},{"text":" an object: this user may edit posts "},{"text":"in rooms they moderate","i":true},{"text":", may read invoices "},{"text":"for their own account","i":true},{"text":", may delete a comment "},{"text":"they wrote, within an hour","i":true},{"text":"."}]},{"t":"p","runs":[{"text":"Write those as a small number of named functions, in one file, and call them from every handler:"}]},{"t":"code","lang":"python","label":"Python","text":"def can_edit_post(user, post):\n    return post.author_id == user.identity_id or user.moderates(post.room_id)"},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"fail-closed-and-be-careful-what-the-error-reveals","runs":[{"text":"Fail closed, and be careful what the error reveals"}]},{"t":"p","runs":[{"text":"Default deny: an unknown role, a missing field, an unhandled case must refuse. Code that reads "},{"text":"if user.role == \"banned\": return 403","code":true},{"text":" allows anyone whose role is an empty string."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"what-to-check-on-your-own-application-this-week","runs":[{"text":"What to check on your own application this week"}]},{"t":"ol","start":1,"items":[[{"text":"Open a resource you own, note the ID, and request the neighbouring ID from a second account. Repeat for every resource type you have."}],[{"text":"Grep for "},{"text":"findById","code":true},{"text":", "},{"text":"get(id)","code":true},{"text":", "},{"text":"where id =","code":true},{"text":" and read what happens next in each one."}],[{"text":"Try the same on write and delete endpoints, and on any endpoint that takes an array of IDs."}],[{"text":"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."}]]},{"t":"p","runs":[{"text":"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."}]}],"check":{"question":[{"text":"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?"}],"options":[{"text":[{"text":"Attackers brute-forcing version-4 UUIDs across the keyspace"}],"correct":false,"tempting":[{"text":"Random UUIDs have far too much entropy for that to be practical; the risk is disclosure of a valid identifier, not guessing one."}]},{"text":[{"text":"Any user who obtains a URL (from a shared link, a referrer header, a screenshot or a support ticket) gains permanent access to the object"}],"correct":true,"tempting":null},{"text":[{"text":"UUID collisions returning another account’s record"}],"correct":false,"tempting":[{"text":"Collision probability for version-4 UUIDs is negligible and is not a security consideration at any realistic scale."}]},{"text":[{"text":"The database cannot index UUIDs efficiently, so the endpoint becomes slow under load"}],"correct":false,"tempting":[{"text":"A real performance consideration for random primary keys, but a performance point rather than the authorisation flaw in question."}]}],"order":[3,0,2,1],"why":null},"prev":{"slug":"the-fallback-when-the-model-is-down","title":"What the page says when the model is not there","minutes":9},"next":{"slug":"sessions-and-logging-people-out","title":"Sessions, and taking one back","minutes":9}}}