A REST API should decide access from the current caller, requested action and stored object's ownership or policy, not from the fact that the caller knows an object ID. Put the decision in a business operation close to the object and make every route use it: detail, list, update, delete, export, attachment and background delivery. Use the account or tenant selected and verified by the server to scope the lookup. Deny when the object, parent relationship, current membership or required permission cannot be established.
The exact rule depends on the product. An invoice may be readable by any current member of its account but refundable only by a billing manager; an auditor may see a limited projection without download rights. Write those rules before choosing a framework guard. OWASP API1:2023 calls out access checks on objects identified in API input. OWASP's authorization guidance calls for deny by default and permission validation on every request.
Illustrative case: an invoice with six routes
Illustrative application, IDs and outcomes: Alder Billing has two customer accounts, Cedar and Elm. A Cedar member may read Cedar invoices. A Cedar billing manager may edit payment details and start an export. An Elm invoice must remain inaccessible to both Cedar roles. No real API or exploit is described.
The first route is GET /invoices/{invoiceId}. Its handler authenticates the user and loads an invoice by ID. The mobile app normally displays only IDs returned by GET /invoices, where a tenant filter already exists. That UI behavior does not secure the detail route: clients can supply an ID directly. The ID could be a UUID and still be a client-controlled reference. OWASP's IDOR testing guidance focuses on testing whether a requester can reach an object outside their entitlement, not whether the identifier is easy to guess.
Map the object before writing the fix:
| Surface | Requested action | Object relationship to check | Output that must respect it |
|---|---|---|---|
| GET /invoices/{id} | Read | Invoice belongs to current account; caller has read permission | Detail body and error |
| GET /invoices | List | Every row belongs to current account | Rows, count, facets, cursor |
| PATCH /invoices/{id} | Edit | Same ownership plus billing-manager permission | State change and returned properties |
| GET /invoices/{id}/pdf | Download | Same invoice and document owner | PDF bytes or download link |
| POST /invoices/{id}/export | Start export | Same ownership plus export permission | Job binding and eventual file |
| GET /exports/{jobId} | Read export | Job and parent invoice belong to current account | Status, file, metadata |
The sixth row is easy to miss: an authorized start does not make a future export download public. OWASP's authorization patterns guidance recommends enforcement near the protected resource, where action, object and tenant can be checked together.
Replace the optional account argument
The following interfaces are illustrative pseudocode, not a production-ready library. The unsafe helper permits a caller to forget the account constraint:
loadInvoice(id, account = null)
query by id
if account supplied: add account predicate
return stored row
The replacement does not return an unrestricted stored row:
readInvoice(context: VerifiedCurrentContext, id, projection: InvoiceReadView)
downloadInvoice(context: VerifiedCurrentContext, id)
updateInvoice(context: VerifiedCurrentContext, id, allowedPatch, expectedVersion)
VerifiedCurrentContext represents context created at a trusted boundary, including the selected account and current actor. A client JSON object with those fields does not qualify merely because it has the same shape. The requested projection is a selector too: the operation intersects it with the caller's permitted fields, and no caller can choose an internal view to bypass field policy. Each operation verifies the stored invoice's account and the required current capability; the update also checks allowed fields and state/version at the write boundary. Missing context or policy-service failure yields no protected result.
Keeping separate operations makes the allowed action harder to accidentally broaden. A generic operation can also work if its action is mandatory and exhaustively handled. The crucial point is that neither a default action nor an omitted account becomes an unrestricted lookup.
Detail now calls readInvoice(context,id,memberView). PDF calls downloadInvoice(context,id), which checks the stored attachment-to-invoice relationship before returning bytes or issuing a narrowly bounded delivery mechanism. Returning a signed URL introduces its own lifetime and revocation contract; do not mark that path complete just because the route calls the new helper.
Define what a mixed batch means before implementing it
Alder adds POST /invoices/batch-update. A Cedar manager submits IDs C1, C2 and E9; E9 belongs to Elm. The payload changes a permitted purchase-order reference. The team must choose a contract. Quietly updating the two permitted invoices and returning a generic success leaves the client unable to know what happened. Returning “E9 exists in Elm” discloses a relationship the caller is not entitled to learn.
For this synthetic write operation, product chooses all-or-nothing behavior: the authorized, current request succeeds for every unique selected invoice, or changes none. Read batches may choose per-item outcomes, but that is a different documented contract.
batchUpdate(context, requestedIds, patch):
validate bounded size, field allowlist, and duplicate-ID rule
resolve every unique ID inside context.account
if resolved set differs from requested set: deny whole operation
authorize action and current state for every resolved invoice
commit updates with the relevant version/policy preconditions
return only the approved update projection
The equality check matters. A query scoped to Cedar correctly returns C1 and C2, but silently dropping E9 does not implement an all-or-nothing promise. The preconditions must cover the interval between authorization and mutation. If the storage system cannot atomically enforce the chosen batch scope, the implementation needs a different transaction boundary or an explicit recoverable workflow; a loop of three ordinary writes is not atomic just because all checks ran first.
| Synthetic input | Expected durable outcome | Caller-visible outcome |
|---|---|---|
| Cedar manager, C1 and C2, unchanged permitted states | Both changed once | Two approved projections |
| Cedar manager, C1 and foreign E9 | Neither changed | Uniform batch denial, no foreign-object details |
| Cedar reader, C1 and C2 | Neither changed | Action denied |
| Cedar manager, C1 and C2; C2 changes version before commit | Neither changed under this contract | Conflict requiring fresh decision |
| Duplicate C1 in one request | Apply documented normalization or reject before work | No duplicate effect |
These are acceptance expectations, not executed results. A production implementation must demonstrate them with its actual transaction and authorization semantics.
A list is not a batch of trusted IDs
A list query starts from an authorized collection and applies filter, sort, pagination and projection to that collection. It cannot safely retrieve an unrestricted page and call the detail helper only on the returned rows if its totals and continuation already describe foreign records. Conversely, IDs previously returned in a list do not replace current authorization for a later batch write. Membership or object state may have changed between requests.
For read batches, define whether a caller receives a uniform denial, a result for each requested slot, or only permitted objects. Each choice needs a documented absence/error policy. Do not reuse the all-or-nothing write contract accidentally. For the query-order example, see pagination information leaks.
Migrate the routes with a finite acceptance record
| Existing surface | Proposed change | Completion evidence still required |
|---|---|---|
| Detail | Mandatory scoped read and member projection | Allowed Cedar body; no Elm content |
| Download operation checks invoice and attachment parent | Storage delivery and stale-link behavior | |
| PATCH / batch update | Allowed fields plus per-object action and commit preconditions | Mixed batch changes none; permitted batch changes all |
| List | Authorized collection before aggregates and pagination | Permitted totals and continuation |
| Export start / result | Persist scoped job; recheck execution and current result reader | Worker and artifact delivery reviewed separately |
Object authorization alone does not settle serialization. If readInvoice returns an authorized Cedar invoice and a CSV renderer emits internal notes, the account check succeeded while property policy failed. Put that fix in the projection contract; do not weaken or discard the object boundary because it cannot answer a different question.
The implementation handoff is now specific: remove the optional-account loader from these public call paths, require trusted context, implement the chosen mixed-batch contract, and record which storage-delivery and worker paths remain incomplete. For investigating whether an apparently missing route check is actually enforced downstream, use the API authorization review.
Make exceptional access explicit in the interface
Alder may have an authorized support operation or migration that needs broader invoice access than ordinary customer routes. Give that purpose its own reviewed authority and interface. Do not preserve the optional account=null behavior as a hidden escape hatch that any new handler can call. The exceptional path should identify its operator, purpose, scope, and permitted output.
This does not require duplicating every query. A shared internal data operation can support several authorized services while public business operations remain mandatory about context and action. The review needs to establish which callers can reach the broad primitive and how they obtain their authority. Naming a helper internal does not enforce that boundary.
For the six customer-facing routes, missing context must remain a failure. A developer should not be able to omit an account argument during a refactor and accidentally receive a global row. The same principle applies to projection selection: an internal view is not available simply because a request names it.
Inspect the final write after a correct lookup
A scoped read can correctly establish that C1 belongs to Cedar, while a later update by ID alone loses the relevant state or relationship condition. Follow the authorization decision to the actual mutation. For Alder's chosen batch contract, each item's authorized state must still be valid at the commit boundary, with the storage mechanism appropriate to the invariant.
A version conflict should trigger reconciliation or a fresh decision. It should not fall back to an unrestricted write to preserve apparent availability. If the operation cannot provide all-or-nothing semantics across its storage boundaries, change the contract or implement a recoverable workflow that makes partial state explicit. Calling a loop “batch update” does not settle atomicity.
Use the migration record to retire old access paths
For each of the six routes, identify the replacement operation and the evidence that the old optional-account loader is no longer used on that path. Include worker and attachment delivery callers, not just HTTP controller imports. If one remains unreviewed, name it in the completion record rather than declaring consistency across the API.
The useful final result is a set of small contracts developers can reuse: mandatory verified context, explicit action, stored object relationship, current permission, permitted fields, and a defined conflict response. They explain why the implementation is consistent without assuming that every route has identical business permissions. A reader and a billing manager can receive different valid outcomes through the same disciplined boundary.
Do not close the issue because the UI never shows Elm IDs, because IDs are hard to guess, or because one route returned not-found for one foreign ID. Show the server rule on each protected path and, where needed, validate it in a controlled environment. For a later patch, follow the fix verification guide to compare the original and new revisions without equating a merged change with a resolved finding.