A developer can correctly require login and still expose another user’s data. The mistake is simple: the application proves who made the request, then assumes that identity is enough to access whichever record the request names.
Consider an endpoint that returns an invoice by identifier. A signed-in user requests invoice 1842, the application loads invoice 1842, and the response succeeds. If the application never checks whether that user is allowed to read that invoice, changing the requested identifier may cross an authorization boundary.
The practical consequence can be unauthorized reading, modification, or deletion of records. The defensive rule is straightforward: authorize the requested action on the specific object before performing it.
This article explains the mental model behind object-level authorization, where the check belongs, how to avoid common implementation gaps, and what this control does not solve by itself.
Authentication answers only the first question
Authentication establishes an identity under some set of assumptions. For example, after a successful login, the application may know that the current principal is user 42.
That fact does not answer a different question:
May user 42 read invoice 1842?That is an authorization decision. It depends on the requested action and object, not merely on whether the requester is signed in.
A useful mental model is:
identity + action + object -> authorization decisionFor a read request, those inputs might be:
identity: user 42
action: read
object: invoice 1842The application should return the invoice only after its policy establishes that this combination is allowed.
Treat object identifiers as references, not permission
Applications commonly expose record identifiers in URLs, request bodies, route parameters, or API fields:
GET /invoices/1842The identifier tells the server which object the client is referring to. It does not prove that the client may access that object.
This distinction matters even when identifiers are difficult to guess. A long random identifier can reduce accidental discovery or broad enumeration, but possession of an identifier is not automatically an authorization rule. Identifiers can appear in logs, browser history, copied links, support messages, analytics systems, or other legitimate application flows.
If access should depend on account membership, ownership, role, delegation, or another policy, enforce that policy explicitly.
Put the authorization decision next to the protected action
Suppose each invoice belongs to an account and a user may read invoices only for accounts they can access. A simplified server-side flow is:
authenticate request
|
identify requested invoice
|
load trusted authorization context
|
can this identity read this invoice?
|
yes -> return invoice
no -> do not return invoiceConceptually, the implementation might look like this:
user = require_authenticated_user(request)
invoice = find_invoice(request.invoice_id)
if invoice is missing:
return not_found
if not can_read_invoice(user, invoice):
return access_denied
return invoiceThis is deliberately pseudocode. Production code should use the framework’s established authentication and authorization mechanisms rather than inventing a parallel security layer.
The important property is the ordering: locating an object does not grant access to it. The protected data or operation is released only after authorization succeeds.
Scope data access when the model allows it
Sometimes the policy can be expressed directly in the data lookup. Instead of loading any invoice and checking ownership afterward, an application might query for an invoice only within accounts the current user may read.
Conceptually:
find invoice 1842
within accounts readable by user 42This can be a strong design because the data-access path naturally excludes objects outside the permitted scope. It also reduces the chance that later code accidentally uses an unauthorized object between lookup and policy evaluation.
However, query scoping is not automatically sufficient for every policy. Authorization may depend on action type, resource state, delegated access, administrative privileges, or relationships that are awkward to represent in one query. In those cases, use a dedicated policy decision as well.
The design goal is not to force every application into one pattern. It is to make unauthorized object access difficult to express accidentally.
Check every operation, not only reads
An object can have different permissions for different actions. A user who may view an invoice may not be allowed to edit, approve, export, or delete it.
Therefore this check is incomplete:
can_access_invoice(user, invoice)when the application actually has several meaningful operations. Prefer decisions that include the action:
can_read_invoice(user, invoice)
can_update_invoice(user, invoice)
can_delete_invoice(user, invoice)or an equivalent policy interface such as:
authorize(user, "delete", invoice)Making the action explicit prevents a broad read permission from silently becoming write permission.
Batch endpoints need the same reasoning. If a request names ten objects, authorization for one object does not imply authorization for the other nine. Either scope the entire query to authorized objects and define partial-result behavior carefully, or evaluate each protected object according to the operation’s policy.
Do not trust ownership claims from the client
A client may send useful context, but authorization facts should come from a trusted server-side source.
For example, a request body might contain:
invoice_id: 1842
account_id: 7The application should not conclude that invoice 1842 belongs to account 7 merely because the client supplied both values. It should establish the relationship from trusted application data and then evaluate whether the authenticated identity may perform the requested action.
The trust boundary is important: request data selects or describes what the client wants. Server-controlled identity, relationship, and policy data determine what the client is allowed to receive or change.
Centralize policy without hiding the object check
Duplicating authorization logic in every route makes drift likely. One handler checks account membership, another checks only login, and a third forgets the check entirely.
A shared authorization layer can reduce that inconsistency. The policy might live in framework policies, service-layer methods, domain authorization functions, or a dedicated policy service. The exact mechanism is platform-dependent.
Centralization should not make the required inputs vague. A policy still needs enough context to answer the real question:
principal + action + target object -> allow or denyBe cautious with helpers that answer only broad questions such as is_authenticated or is_admin when the protected operation depends on a relationship to a particular object.
Decide how missing and unauthorized objects appear externally
Applications sometimes return the same external response for a nonexistent object and an object the requester cannot access. This can reduce information disclosed through direct responses about which identifiers exist.
That choice is contextual, not a universal requirement. API semantics, supportability, client behavior, and the sensitivity of object existence all matter.
More importantly, response shaping is not the authorization control. Returning 404 instead of 403 does not protect an object if the application already exposed its data. First ensure unauthorized requests cannot perform the protected action; then choose an external error model appropriate to the application.
Internally, logs and diagnostics can preserve enough distinction for investigation, provided they do not expose sensitive data to unauthorized users.
Test the boundary with two identities
A positive test proves that permitted access works. It does not prove that cross-object access is rejected.
A small authorization test should create at least two security contexts. For example:
user A -> account A -> invoice A
user B -> account B -> invoice BThen verify the relevant boundary:
user A reads invoice A -> allowed
user A reads invoice B -> not allowedRepeat the negative case for protected write operations, not only reads. If roles or delegated relationships affect the policy, add cases for those meaningful boundaries as well.
These tests are especially valuable when adding new endpoints. A route can reuse an existing data model while accidentally omitting the authorization behavior enforced elsewhere.
Understand what object-level authorization does not solve
This control reduces the risk that an authenticated or otherwise reachable client can access a protected object outside its permitted scope. It assumes the authorization policy itself correctly represents intended access and that the application evaluates trustworthy identity and relationship data.
It does not by itself protect against a compromised account accessing objects that account legitimately controls. It also does not replace authentication security, session protection, input validation, rate controls, audit logging, or safeguards around sensitive business actions.
Authorization can also become stale. If access is revoked, cached permissions or long-lived derived decisions may continue to grant access until they are refreshed. Systems with strict revocation requirements need cache and session behavior that matches those requirements.
Finally, object-level checks do not fix over-broad policy. If every ordinary user is intentionally assigned permission to every invoice, the check can operate exactly as written and still provide more access than the business requires. Least privilege remains a separate design concern.
Choose the simplest enforceable boundary
For a small application with simple ownership rules, scoping queries to the authenticated user’s records may be enough. For applications with teams, delegated access, multiple actions, or sensitive administrative operations, an explicit policy layer usually makes the decision easier to review and test.
Defense in depth is justified when mistakes have high impact. Useful complementary controls include least-privilege roles, strong authentication, audit records for sensitive operations, and tests that exercise cross-user and cross-tenant boundaries.
The core rule stays the same regardless of architecture: an object identifier tells the application what the client requested. Only an authorization decision should determine whether that client may perform the requested action on that object.