---
key: "object-authorization"
title: "Enforce authorization for every object action"
url: "https://softwaresecurity.com/domains/appsec/authorization/object-level-authorization/"
api: "https://softwaresecurity.com/api/v1/articles/object-authorization.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-639","CWE-862","CWE-863","A01:2025","API1:2023","V8.2.2","V8.3.1","V8.4.1","WSTG-ATHZ-04"]
tags: ["Access control","API security","Security testing"]
stages: ["build","test"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Enforce authorization for every object action

Authorize the authenticated subject, requested operation, and specific resource together; a valid login or unpredictable identifier is insufficient.

Canonical URL: https://softwaresecurity.com/domains/appsec/authorization/object-level-authorization/

## In short

Check permission for the specific object on every request, on the server, after loading it and before releasing anything. Take the caller's id, tenant and roles from the verified session, never from the request. Encode the whole rule, for example active AND same tenant AND (owner OR billing admin), and apply it to lists, exports, attachments and cached responses too. Random ids make guessing harder but are not authorization.

- Authentication says who is calling; object authorization says whether this caller may perform this action on this record now.
- Keep role exceptions inside the tenant boundary: `active && sameTenant && (owner || billingAdmin)`.
- Scope the query to permitted records, or check the loaded record before serializing, logging or caching it.
- Return the same 404 for missing and forbidden records when the contract hides existence.
- Apply the rule to lists, counts, search, exports, attachments, background jobs and caches, not only the detail route.

## Verify it

1. Run a matrix that changes one fact at a time: owner, tenant, role, active flag and a missing id.
2. Call the API directly with another user's object id and assert that no fields, totals or links appear in the response.
3. Revoke membership or transfer the record between two requests and confirm the second request is denied, including from cache.
4. Request a bulk export containing one forbidden id and confirm the documented atomic or partial result.
5. Check every route that loads the record and confirm the decision runs before serialization, logging or caching.

## What you will learn

- Translate a business rule into a complete predicate.
- Verify tenant and ownership exceptions independently.
- Find authorization bypasses across routes and caches.

Audience: API and backend developers adding record-level access checks, and reviewers who test them.

Before you start:

- Distinguish the authenticated actor, active tenant, record owner, and requested action.

Time: About 24 minutes: 8 to read and 16 for the exercise

## Turn a role label into a decision rule

Authentication answers who is making the request. Object authorization answers whether that identity may perform this action on this particular resource now. A route such as `GET /invoices/502` contains an attacker-controlled selector, even when the interface never displays invoice 502. Random identifiers reduce guessing but cannot make a leaked link safe. The server must evaluate permission after establishing identity and before releasing the selected data.

Use this concrete rule: an active member may read an invoice only when its tenant equals the member's tenant and its owner equals the member's user id; a billing administrator may read any invoice within that administrator's tenant. The administrator exception does not remove the tenant boundary. Write the rule with parentheses: `active AND sameTenant AND (owner OR billingAdmin)`. An expression that places `billingAdmin` outside the tenant conjunction accidentally creates a global administrator. Define update, download, refund and export separately rather than assuming read permission grants them all; /domains/appsec/authorization/function-property-authorization/ covers those action-level checks.

## Place selection and permission on the same path

A vulnerable implementation authenticates Alice, reads the requested invoice by id alone and serializes it. A repaired implementation either scopes the database query to permitted records, for example `WHERE id = ? AND tenant = ? AND (owner = ? OR ? = 'billing_admin')`, or evaluates the complete predicate on the loaded record before serialization. Derive user id, tenant and roles from verified server context. Taking tenant from a request header and user id from a JSON body simply lets the caller provide the answer to the permission question.

The same boundary applies to nested routes. A URL containing `/tenants/red/invoices/502` is not proof that invoice 502 belongs to red. Check the actual relationship. Apply the rule to list results, counts, search indexes, attachment endpoints and background exports as well as the primary detail route. A bulk endpoint should authorize every selected object or reject the request atomically according to a documented contract. Checking only the first object leaves later identifiers uncontrolled.

The module below is a runnable version of the rule. Save it as `invoice-access.mjs` and run `node invoice-access.mjs` with Node.js 22 or later; it prints `PASS: 7 authorization cases`. `readInvoice` returns the same 404 body for a missing invoice and a forbidden one, and its response is an explicit projection rather than the stored record. Moving the role check outside the tenant comparison makes the Cara/601 assertion fail, which is the regression this test exists to catch.

```javascript
// Run: node invoice-access.mjs
import assert from 'node:assert/strict';

// Server-side records; in production this is a database lookup.
const invoices = new Map([
  ['501', {id: '501', tenant: 'red', owner: 'A', total: 40}],
  ['502', {id: '502', tenant: 'red', owner: 'B', total: 70}],
  ['601', {id: '601', tenant: 'blue', owner: 'D', total: 90}],
]);

// The complete rule, with the role exception inside the tenant boundary.
export function canRead(caller, invoice) {
  return (
    caller.active === true &&
    caller.tenant === invoice.tenant &&
    (caller.id === invoice.owner || caller.role === 'billing_admin')
  );
}

// `caller` comes from the verified session, never from the request body or headers.
export function readInvoice(caller, id, repo = invoices) {
  const invoice = repo.get(String(id));
  if (!invoice || !canRead(caller, invoice)) return {status: 404, body: {error: 'not_found'}};
  return {status: 200, body: {id: invoice.id, total: invoice.total}}; // explicit projection
}

const alice = {id: 'A', tenant: 'red', role: 'member', active: true};
const cara = {id: 'C', tenant: 'red', role: 'billing_admin', active: true};
const dan = {id: 'D', tenant: 'blue', role: 'billing_admin', active: true};
const denied = {status: 404, body: {error: 'not_found'}};

assert.deepEqual(readInvoice(alice, 501), {status: 200, body: {id: '501', total: 40}});
assert.deepEqual(readInvoice(alice, 502), denied); // not the owner
assert.deepEqual(readInvoice(cara, 502), {status: 200, body: {id: '502', total: 70}});
assert.deepEqual(readInvoice(cara, 601), denied); // admin role stays inside its tenant
assert.deepEqual(readInvoice(dan, 601), {status: 200, body: {id: '601', total: 90}});
assert.deepEqual(readInvoice(cara, 999), denied); // missing looks the same as forbidden
assert.deepEqual(readInvoice({...cara, active: false}, 502), denied); // revoked member
console.log('PASS: 7 authorization cases');

```

## Use a matrix that changes one fact at a time

The fixture below makes the exception and boundary independently visible. Alice owns invoice 501 in red. Ben is another ordinary red member. Cara is a red billing administrator. Dan is a blue billing administrator. Invoice 502 belongs to Ben in red; invoice 601 belongs to Dan in blue. Comparing Alice/501 with Alice/502 isolates ownership. Comparing Cara/502 with Cara/601 isolates tenant scope despite the same privileged role.

For the stated contract, every denied lookup returns the same 404 body as a missing invoice. That avoids deliberately advertising existence; it does not guarantee identical timing or hide information available elsewhere. Assert no invoice fields, totals, attachment locations or cache entries for the denied resource escape. Also test revocation: mark Cara inactive before a second request and require denial. The IDOR lab at /labs/authorization/ runs the same ownership and tenant cases against a handler you fix yourself. If permissions are cached, the cache must include identity and relevant policy context, with an explicit invalidation or expiry design.

## Keep authorization understandable during operation

A useful decision log contains an internal request id, actor id, action, resource reference, tenant context, outcome and policy reason. It should not contain the invoice document or session token. Group denied requests to spot repeated cross-tenant probing while recognizing that stale bookmarks also cause legitimate denials. A log is evidence of what the service decided, not a substitute for preventing the response.

Centralizing a policy helps consistency, but a helper can still be skipped. Review every entry point and make unauthorized resource access difficult by construction, for example through a repository function that requires caller context. For writes, keep permission and state changes consistent with concurrent revocation or ownership transfer; high-impact actions may require a transaction or a conditional update. Test both the policy and the route because a perfect policy function provides no protection when a handler serializes data before calling it.

## Enforce the rule with your framework, not around it

Framework features help only when they see the loaded resource. A route-level role check such as an `[Authorize]` attribute or a `@PreAuthorize("hasRole(...)")` annotation knows the caller but not the invoice, so it cannot decide ownership or tenant. Use the resource-aware hook each framework provides (/fix/missing-authorization/ has a fix for each common stack), and keep list endpoints on a filtered query because per-object checks rarely run for every row of a list.

- Django REST framework: `has_object_permission` runs only when the view calls `get_object()`; list views need a `get_queryset` filtered to the caller.
- Spring Security: method security with `@PreAuthorize` or `@PostAuthorize` that passes the loaded object to a policy bean, or a custom `AuthorizationManager`.
- ASP.NET Core: resource-based authorization, `IAuthorizationService.AuthorizeAsync(User, invoice, "CanRead")`, called after loading the invoice.
- Rails: scope through the association, for example `current_user.invoices.find(params[:id])`, or Pundit `authorize` with `policy_scope` for lists.
- Policy engines such as OPA, Cedar or OpenFGA centralize the rule; the application must still call them with the loaded resource and act on a denial.
- PostgreSQL row-level security adds defense in depth for tenant scope. Superusers and `BYPASSRLS` roles always bypass it, and table owners do too unless the table uses `FORCE ROW LEVEL SECURITY`.
- GraphQL: authorize in every resolver or in the shared data loader, because one query can reach the same object through several fields.

## A real-world failure

In 2019 First American Financial exposed hundreds of millions of title and escrow document images (about 800 million by the SEC's count, 885 million by the reporting journalist's) because any document link could be edited to a sequential number to read another customer's file, with no authorization check and no authentication required. This is a textbook insecure direct object reference; the missing control was authorizing every object access against the caller. The regulators' orders add the governance failure: the company's own penetration test had reported the flaw months earlier, and a clerical error logged it at a lower severity so it was never fixed (/domains/appsec/authorization/case-study-first-american/).

## Choose bulk and delegated-access semantics explicitly

Suppose Alice requests invoices [501, 502] in one export. Under the fixture's ownership rule, she may read only 501. The endpoint needs a deliberate contract: reject the entire request, or return a documented partial result that contains only permitted objects. This guide chooses atomic rejection for an explicitly selected export: the result is a denial and zero exported invoices. Quietly dropping 502 may be acceptable for a broad search, but can mislead a caller who believes a requested financial export is complete. The distinction comes from the operation's meaning, not a universal rule that partial responses are always wrong.

For delegated access, add an explicit grant with subject, resource scope, action and expiry. A support engineer temporarily allowed to read invoice 501 does not automatically gain permission to refund it, export the entire tenant or retain access after the grant expires. Evaluate grants together with current tenant and resource state. A pre-signed download link is itself a bearer capability: whoever has the link may be able to retrieve the file until expiry. Issuing one is therefore an authorization-sensitive action with a lifetime decision, not merely a performance optimization.

When testing delegation, hold the resource constant and vary action and time. With a grant that expires at time 10, a read at time 9 may succeed, while a refund at time 9 and a read at time 10 should both be denied under an exclusive-expiry rule. Record that exact boundary in the policy rather than leaving different services to interpret it differently.

## Guided practice

Evaluate a complete invoice permission matrix; then review the route ordering.

### Supplied material

```text
Callers: Alice=(id A, tenant red, member, active); Ben=(B, red, member, active); Cara=(C, red, billing_admin, active); Dan=(D, blue, billing_admin, active).
Invoices: 501=(red,A,total 40); 502=(red,B,total 70); 601=(blue,D,total 90).
Requests: A/501; A/502; C/502; C/601; D/601; C/999.
Policy: active AND sameTenant AND (owner OR billing_admin).
Denial contract: 404 {error: not_found}; no invoice fields.
Buggy cache key: invoice:{id}.
```

### 1. Compute the decisions

Apply every term of the predicate to the six requests and write status plus visible total.

Worked answer: A/501 → 200,40; A/502 → 404; C/502 → 200,70; C/601 → 404; D/601 → 200,90; C/999 → 404. Cara's role does not bypass tenant scope.

### 2. Find the route bypass

Consider a handler that loads and serializes invoice 601 before calling `canRead(Cara, invoice)`. Identify the earliest forbidden effect.

Worked answer: Serialization may already expose fields through a response, log or cache. The decision belongs before any release. Loading internally can be acceptable; releasing data before denial cannot.

### 3. Evaluate revoked membership

Set `Cara.active=false` and repeat C/502. Then restore active and transfer invoice 502 to blue.

Worked answer: Both variants return 404. The first fails active; the second fails sameTenant. A cached earlier allow must not silently override either change.

### 4. Repair response caching

Dan first caches invoice 601 under `invoice:601`. Cara requests the same id. Explain why a cache hit cannot skip the policy.

Worked answer: The shared key identifies the object but not permission. Authorize against current object metadata before returning cached content, or use a policy-aware cache with invalidation. Cara must receive 404 even if the document is already cached.

Deliverable: A six-row access table, two revocation results and a cache design that cannot return a foreign invoice.

Scope: Paper exercise: every caller, invoice and policy term is supplied above. The runnable `invoice-access.mjs` module and the browser lab exercise the same rule in code.

## Executable practice

- [IDOR lab: Repair an invoice authorization bug](https://softwaresecurity.com/labs/authorization/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Authorization](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [OWASP Insecure Direct Object Reference Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)
- [OWASP API1:2023 Broken Object Level Authorization](https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/)
- [Django REST framework: permissions and object-level checks](https://www.django-rest-framework.org/api-guide/permissions/)
- [ASP.NET Core: resource-based authorization](https://learn.microsoft.com/en-us/aspnet/core/security/authorization/resource-based?view=aspnetcore-10.0). ASP.NET Core 10.0.
- [PostgreSQL: row security policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html). PostgreSQL 18.
- [KrebsOnSecurity: First American Financial Corp. leaked hundreds of millions of title insurance records](https://krebsonsecurity.com/2019/05/first-american-financial-corp-leaked-hundreds-of-millions-of-title-insurance-records/). 24 May 2019.
- [SEC: In the Matter of First American Financial Corporation, Release No. 34-92176 (order)](https://www.sec.gov/litigation/admin/2021/34-92176.pdf). 14 June 2021.