Authentication answers "who is this," and it's the part everyone remembers to build. Authorization answers "is this identity allowed to do this specific thing to this specific resource," and it's the part that gets skipped on the third endpoint someone adds after the initial security review. Broken object-level authorization — checking that a token is valid but not that the token's owner actually owns the resource being requested — has been at or near the top of the OWASP API Security Top 10 for years, precisely because it's easy to get right on paper and wrong in practice, one route at a time.
RBAC answers "what," not "whose"
Role-based access control assigns permissions to roles (admin, editor, viewer) and roles to users. It's the right model for coarse-grained questions: can this user delete invoices at all, can this user access the admin panel. It answers nothing about ownership. A user with the editor role who can edit invoices in general still shouldn't be able to edit invoice #4471 that belongs to a different customer account, and RBAC alone has no concept of "this specific record." Teams that stop at RBAC and call authorization done are the ones that end up with an IDOR (insecure direct object reference) bug the first time someone changes an ID in the URL.
GET /v1/invoices/4471 HTTP/1.1
Authorization: Bearer eyJhbGciOi...
# Server-side, every time, not just at login:
# 1. token valid? -> 401 if not
# 2. role permits 'invoices:read'? -> 403 if not
# 3. invoice.account_id == token.account_id? -> 404 if not
# (404, not 403 -- don't confirm the record exists)
Object-level checks belong in the resource layer, not the router
The fix for broken object-level authorization is structural: the check that a resource belongs to the caller has to happen where the resource is loaded, not in a middleware that only sees the route pattern. A middleware can confirm the token has an invoices:read scope; it usually can't (and shouldn't have to) know that invoice 4471 belongs to account 88 until the resource is actually fetched. That means every handler that loads a resource by ID needs an explicit ownership or tenancy check as part of the query itself — WHERE id = ? AND account_id = ?, not WHERE id = ? followed by a separate check that's easy to forget on the next endpoint.
ABAC for conditions that aren't roles
Attribute-based access control extends RBAC with conditions: this user can approve this expense report only if the amount is under their approval limit, only during business hours, only if they're not the report's submitter. These are policies about the request's attributes (amount, time, submitter identity) rather than the user's role, and trying to model them as more and more granular roles ("editor-under-500", "editor-under-5000") turns your role table into an unmaintainable mess. A small policy engine — even a plain function that takes (user, action, resource, context) and returns allow/deny — scales better than a role explosion once you have more than two or three conditional rules.
A 403 on a resource the caller doesn't own confirms the resource exists — useful information for an attacker enumerating IDs. Returning 404 for both "doesn't exist" and "exists but isn't yours" leaks nothing. Reserve 403 for cases where the resource's existence is not itself sensitive, such as a known public endpoint the caller lacks a scope for.
Testing authorization like a first-class feature
Authorization bugs don't show up in a normal happy-path test suite because the happy path is, by definition, a caller accessing their own resources correctly. The tests that matter are the adversarial ones: authenticate as user A, request user B's resource by ID, assert a 404. Every resource endpoint should have this test, generated as a matrix if you have many similar routes, because it's the one class of test that a code reviewer skimming a diff is least likely to notice is missing.
A request body or query string that includes "role": "admin" or "account_id": 1 is a client claim, not a fact. Authorization decisions must be derived from the authenticated token's server-side claims, never from fields the caller can set in the request itself — otherwise privilege escalation is one JSON edit away.
Wrapping up
Authentication and authorization are different problems solved at different layers: RBAC for coarse role checks, ABAC for conditional policy, and — the one that actually gets skipped — an explicit ownership check baked into every resource query. Test the adversarial path deliberately, because a clean happy-path test suite will never surface a broken object-level authorization bug on its own.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.