An application can authenticate a user correctly and still expose another user’s data. The failure often happens when an endpoint accepts a resource identifier, loads that resource, and assumes that knowing the identifier is enough to use it.
It is not.
A resource ID answers which object the client wants. Authorization must separately answer whether this actor may perform this action on that object.
This article develops a practical mental model for object-level authorization, shows where checks belong, and explains how to avoid common gaps when applications grow beyond simple owner-only data.
The threat is a valid user requesting the wrong object
Consider an application where an authenticated user can view an invoice:
GET /invoices/4821The server receives a valid session and an invoice ID. If it only checks that the caller is signed in before loading invoice 4821, any authenticated user who can supply another valid ID may receive data that does not belong to them.
The problem is not necessarily weak authentication. The caller may be exactly who they claim to be. The missing decision is whether that identity is allowed to access this particular invoice.
The same pattern can affect updates and deletions:
PATCH /projects/91
DELETE /documents/305
POST /teams/44/membersChanging numeric IDs to UUIDs can make identifiers harder to guess, but it does not create an authorization boundary. Identifiers can appear in logs, browser history, links, API responses, analytics systems, support messages, or other legitimate workflows. Treat every identifier supplied by a client as a locator, not as proof of permission.
Use a four-part authorization question
A useful authorization decision contains four parts:
actor + action + target + context -> allow or denyFor example:
actor = user_17
action = invoice.read
target = invoice_4821
context = organisation_6This model is more precise than asking only whether the user has a role such as member or editor.
A role can help determine what actions are generally available, but object-level authorization adds the resource boundary. A project editor might be allowed to update projects in one organisation without being allowed to update every project in the system.
Context can matter too. The same user may have different permissions depending on the organisation, workspace, account, environment, or temporary delegation under which the request is being made.
Put the check next to the protected operation
The safest application structure makes the authorization decision difficult to bypass accidentally.
A simplified service flow might look like this:
actor = requireAuthenticatedUser(request)
invoice = loadInvoice(request.invoiceId)
if not canReadInvoice(actor, invoice):
denyAccess()
return presentInvoice(invoice)The important property is the sequence: identify the actor, identify the target, authorize the requested action, and only then expose or modify protected data.
Do not rely on the user interface to hide links or buttons. A client can construct requests directly. Server-side authorization is the security boundary.
Also avoid scattering slightly different ownership checks throughout controllers and handlers. Central authorization functions or policy objects make rules easier to review, test, and reuse. The exact implementation depends on the application, but the decision should remain explicit.
Scope queries when the access rule is simple
For owner-only resources, authorization can often be expressed directly in the data lookup.
Instead of conceptually doing this:
invoice = findInvoiceById(invoiceId)
if invoice.ownerId != actor.id:
denyAccess()the application can query within the actor’s permitted scope:
invoice = findInvoiceByIdAndOwner(invoiceId, actor.id)
if invoice does not exist:
denyAccess()This approach reduces the chance that code loads a resource and forgets the ownership check before returning it. It can also avoid revealing whether an inaccessible object exists.
However, query scoping is not a complete authorization model for every application. Real systems may support organisation membership, shared resources, delegated access, administrators, temporary grants, or action-specific rules. When the policy becomes richer, keep the authorization logic explicit rather than forcing every rule into an opaque database query.
The goal is not to prefer one implementation technique. The goal is to ensure that every protected object is reached through an enforceable access boundary.
Distinguish ownership from permission
Ownership is a useful rule, but it is only one kind of authorization relationship.
Suppose a document belongs to organisation acme. Three people may interact with it differently:
- an author can read and edit it;
- a reviewer can read and comment but not delete it;
- an organisation administrator can manage sharing settings.
A single comparison such as document.owner_id == user.id cannot represent these permissions accurately.
Model the actions that matter to the application. For example:
document.read
document.edit
document.comment
document.delete
document.shareThen define which relationships or roles permit each action. This makes an important security distinction visible: permission to read an object does not imply permission to modify, delete, or share it.
Avoid a generic canAccess(document) check when different operations have meaningfully different risk. A broad check can quietly grant write capabilities to code that was originally designed only for reading.
Nested URLs do not prove the relationship
Nested routes can look reassuring:
GET /organisations/6/projects/91But the URL itself does not prove that project 91 belongs to organisation 6, or that the caller belongs to either one.
The server must validate the relationships that the security decision depends on. A robust flow might verify that:
- the actor has access to organisation
6; - project
91belongs to organisation6; - the actor has permission to read that project.
Skipping the second check can create a subtle cross-tenant problem if the application loads the organisation and project independently.
The same principle applies to request bodies. If a client sends organisation_id, owner_id, or another security-sensitive relationship, do not trust that value merely because it is syntactically valid. Derive security context from trusted server-side state where practical, and authorize any relationship change explicitly.
Create operations need object-level thinking too
Object-level authorization is not limited to retrieving existing records.
A create request often selects a parent object:
POST /projects/91/tasksBefore creating the task, the server should verify that the actor may create tasks in project 91. Otherwise, a user might attach new data to a project they cannot legitimately use.
Updates deserve similar care when they can move an object between parents or change ownership. An actor who may edit a document’s title does not automatically have permission to transfer that document to another organisation.
Treat security-sensitive relationship changes as distinct actions rather than ordinary field updates.
Batch operations must authorize every target
Bulk endpoints can accidentally weaken an otherwise sound policy.
Imagine a request that archives several records:
POST /documents/archive
ids = [12, 18, 27]Checking permission for only the first document is not enough. The server must ensure that the requested action is authorized for every target that will be affected.
A useful design is to scope the operation to authorized resources and then verify that the resulting set matches the intended request semantics. Be deliberate about partial success. Silently operating on only the permitted subset may be appropriate for some workflows, while other workflows should reject the entire request if any target is unauthorized.
Whichever behaviour you choose, document and test it. Ambiguous bulk semantics are a common place for authorization assumptions to diverge between developers.
Be deliberate about not-found and forbidden responses
Applications often choose between returning a not-found response and an explicit forbidden response when an object exists but the caller cannot access it.
Returning the same not-found result for both inaccessible and nonexistent objects can reduce information disclosed about resource existence. This is useful when existence itself is sensitive.
An explicit forbidden response can be more informative for resources whose existence is already known, such as a shared workspace where the user lacks one particular action.
There is no universal response policy for every application. Choose based on the threat model and user experience, then apply the choice consistently. More importantly, never let the response distinction replace the authorization check itself.
Test authorization as a matrix, not a happy path
A test that proves the owner can read an object is only half of the security story. Authorization tests should include negative cases.
For a protected operation, test combinations such as:
| Actor | Target | Action | Expected result |
|---|---|---|---|
| owner | own object | read | allow |
| owner | own object | edit | allow if policy permits |
| other user | owner’s object | read | deny |
| other tenant member | foreign tenant object | read | deny |
| read-only collaborator | shared object | read | allow |
| read-only collaborator | shared object | edit | deny |
Add cases for deleted memberships, expired grants, role changes, transferred resources, and administrative exceptions when the application supports them.
These tests do more than verify individual functions. They document the intended security policy in a form that can catch regressions when routes, roles, and data relationships change.
Central policy does not remove enforcement responsibility
A shared authorization library or policy engine can make complex rules easier to maintain, but centralization does not help if some code paths never call it.
Inventory the ways protected data can be reached: HTTP endpoints, background jobs, administrative tools, message consumers, import processes, scheduled tasks, and internal APIs may all perform actions on the same objects.
Each trusted execution path needs an appropriate authorization model. A background job acting with system authority may not use an end-user policy, but that authority should be intentional and narrowly scoped rather than an accidental bypass.
This is also where least privilege complements object-level authorization. Object-level checks decide whether a particular actor may perform a particular action on a target. Least privilege limits the broader capabilities available to application components and identities. Using both reduces the impact of mistakes at either layer.
Know what this control does not solve
Object-level authorization reduces the risk of one identity reaching resources outside its permitted scope. It does not solve every access problem.
It does not compensate for stolen credentials or a hijacked session if the attacker is using the victim’s legitimate permissions. It does not replace function-level authorization for administrative operations. It does not validate untrusted input, protect secrets at rest, or detect every abuse performed within legitimately granted access.
Complement it with strong authentication, session protection, least privilege, secure input handling, meaningful security logging, and monitoring appropriate to the application’s risk.
A practical review rule
When reviewing a protected endpoint or service operation, trace one question from input to side effect:
What server-side fact proves that this actor may perform this action on this exact target?
If the answer is only “the user is logged in”, “the ID is hard to guess”, “the UI does not show that button”, or “the route contains the right organisation ID”, the authorization boundary is incomplete.
A sound design makes the actor, action, target, and relevant context explicit. It enforces that decision on the server for every protected operation and tests both allowed and denied cases. That turns object identifiers back into what they should be: ways to locate resources, not keys that grant access to them.