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.
Applies to

Server-side object reads and writes, including list, export, and background routes. UUIDs do not replace policy.

Part of
Learning paths: Developer foundations (step 3 of 13), AppSec in practice (step 2 of 18). Workflows: Fix a finding, Secure a feature, Review a design, Test an application

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 24 minutes: 8 to read and 16 for the exercise

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.

Before you start

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

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

Refresh first: Session management: cookies, timeouts, rotation and revocation

Go to the guided practice

One object request through the authorization check

UntrustedYour serviceClientAPI handlerPolicy checkDatabaseGET /invoices/502 withsession1Caller from theverified session2Load invoice 502 or a scoped query3Invoice with tenant and owner3canRead(caller,invoice)4Allow or deny4200 projection, or thesame 4045Trust boundaryUntrusted input1Step, described below
  1. Request an objectThe client sends GET /invoices/502 with its session. The id in the path is attacker-controlled, even when the interface never showed invoice 502.
  2. Identify the callerThe handler takes the user id, tenant, roles and active flag from the verified session, never from a header, query parameter or body field.
  3. Load or scopeThe handler loads invoice 502 with its tenant and owner, or runs a query already scoped to the records the caller may read.
  4. DecideThe policy evaluates the whole rule on the loaded record, active && sameTenant && (owner || billingAdmin), before anything is serialized, logged or cached.
  5. RespondOn allow, the handler returns an explicit projection of the permitted fields. On deny, it returns the same 404 as a missing invoice and logs the decision.
The id in the URL selects a record; it does not authorize access to it. The server decides for every object, after loading it and before releasing anything.

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.

Fixedinvoice-access.mjsJavaScript
// 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 scenario material
Plain 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. 1. Compute the decisions

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

    Response for each request

    A/501
    A/502
    C/502
    C/601
    D/601
    C/999

    Expected result and reasoning

    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. 2. Find the route bypass

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

    Expected result and reasoning

    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. 3. Evaluate revoked membership

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

    Response for C/502 after each change

    Cara.active=false
    Cara active again; invoice 502 moved to blue

    Expected result and reasoning

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

  4. 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.

    Expected result and reasoning

    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.

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

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.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

IDOR lab: Repair an invoice authorization bug

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Authorization
  2. OWASP Insecure Direct Object Reference Prevention
  3. OWASP API1:2023 Broken Object Level Authorization
  4. Django REST framework: permissions and object-level checks
  5. ASP.NET Core: resource-based authorization. ASP.NET Core 10.0.
  6. PostgreSQL: row security policies. PostgreSQL 18.
  7. KrebsOnSecurity: First American Financial Corp. leaked hundreds of millions of title insurance records. 24 May 2019.
  8. SEC: In the Matter of First American Financial Corporation, Release No. 34-92176 (order). 14 June 2021.

Reference information

ASVS 5.0.0 requirements
v5.0.0-8.2.2 · v5.0.0-8.3.1 · v5.0.0-8.4.1
OWASP WSTG v4.2 tests
WSTG-ATHZ-04

Download this guide as Markdown