To check that list and detail endpoints enforce the same authorization rule, start with the rule for one object, then exercise both routes with two identities that should get different results. Confirm the list omits forbidden objects and their metadata; confirm a direct request for a forbidden ID is denied. Repeat after a permission change and through any search, count or export that serves the same objects. The answer changes when a list intentionally has a narrower projection than detail: in that case each endpoint still needs its own explicit object and field policy. A list result does not authorize later detail access, and hiding an item in the UI does not protect its URL. OWASP recommends checking permissions on every request (Authorization Cheat Sheet).
Use accounts and records you own in a local or authorized staging environment. An authorization check is about the server's response to a caller, not about whether a tester can guess an ID. OWASP's IDOR testing guide uses separate test users and known object IDs for precisely this reason.
Define the policy before sending requests
Write one sentence per operation. For example: “A billing editor may list and read invoices belonging to an account where they currently hold the billing-read capability; a support user may not.” Specify whether the list may show less detail than the object page, whether a tenant administrator has a separate grant, and what a removed member should receive. Without an expected decision, 200 or 404 alone is hard to interpret.
Then identify the real enforcement points. The route may call a shared query, while the detail path might fetch by global ID. A gateway may authenticate the user but leave object authorization to the service. A background export may bypass both handlers. OWASP's API1:2023 Broken Object Level Authorization describes the failure where a caller manipulates an object identifier and reaches another user's object despite being authenticated.
Illustrative case: the invoice list is safe, the detail route is open
Illustrative case; all identities, IDs and responses are invented. Cedar Desk is a SaaS application with accounts Cedar-A and Cedar-B. Ava has billing-read access in Cedar-A. Ben has a support role in Cedar-A. Bea has billing-read access only in Cedar-B. Invoice INV-A-17 belongs to Cedar-A; INV-B-42 belongs to Cedar-B.
The list handler first selects invoices for the active account and filters them by role. The first detail-handler draft calls findInvoice(id) and returns the row after checking only that the requester is signed in. A normal UI run looks fine because Bea never sees INV-A-17 in her list. A controlled request for its known ID would reveal whether the detail route also enforces the account and billing-read rule. No real customer record needs to be used to establish this property.
Capture a small decision matrix before changing code:
| Caller and operation | Expected | Evidence to record |
|---|---|---|
| Ava lists Cedar-A invoices | INV-A-17 visible; Cedar-B objects absent |
IDs, field names, count and cursor |
Ava reads INV-A-17 |
Allowed billing detail | Resource parent and permitted fields |
| Ben lists Cedar-A invoices | No billing objects or informative facets | Response body, count and filters |
Ben reads INV-A-17 directly |
Denied | Status, body and audit decision |
| Bea lists Cedar-B invoices | INV-B-42 visible; Cedar-A absent |
IDs, counts and next-page behavior |
Bea reads INV-A-17 directly |
Denied | Status, body and service decision |
| Ava after billing-read removal | Future list and detail delivery denied | Policy version and revocation time |
The status code is only one observation. Depending on your API's documented semantics, both 403 and an indistinguishable 404 may be acceptable for a denied detail request. The key property is that the caller receives no forbidden object or metadata. Do not infer correctness from a response code if the error body includes an invoice number, customer name or existence hint.
Read the response, not just the green assertion
Use distinct harmless fixture markers: Cedar-A's invoice contains CEDAR_A_ONLY; Cedar-B's contains CEDAR_B_ONLY. The following transcript is synthetic expected-review material, not the result of requests performed for this article.
GET /accounts/Cedar-B/invoices
Authorization: <Bea's test credential>
200
{"items":[{"id":"INV-B-42","label":"CEDAR_B_ONLY"}],"total":1}
This is a useful permitted path. It says nothing yet about a direct Cedar-A request. A first repair adds an owner check but leaves the error serializer unchanged. Inspect its superficially denied response:
GET /invoices/INV-A-17
Authorization: <Bea's test credential>
404
{"error":"Not found","invoice_number":"CEDAR_A_ONLY"}
The status matches a common denial convention, but the body fails the policy: the error handler serialized metadata from a forbidden record. The repair must protect error construction as well as the successful response. A generic error body is the expected output here:
{"error":"Not found"}
That does not establish that timing or other endpoints are indistinguishable. It resolves the specific content leak shown in the transcript.
A second trap is a safe-looking page with a global count. If Bea receives one Cedar-B item and total: 2 because the count includes Cedar-A, the list query and aggregate implement different scopes. Pagination can have the same defect: filtering forbidden rows after applying a global page limit can produce empty pages and misleading totals. Inspect where the authorized predicate enters both retrieval and aggregate computation; do not patch only the React card.
Distinguish denial from a broken fixture
Suppose every request, including Ava's own INV-A-17 detail, returns 401. The result is inconclusive for object authorization: the credential or authentication setup prevented the policy from being exercised. Suppose list succeeds but detail returns 503 because its database is unavailable. That is also not an observed object deny.
| Synthetic observation | Review decision | Next useful action |
|---|---|---|
| Ava own detail allowed; Bea foreign detail returns generic deny | Supports the tested direct-object boundary | Check body, side effects and relevant deployment identity |
Both identities receive 401 |
Authentication fixture failure | Repair credentials, then repeat the paired comparison |
| Bea gets generic deny but audit shows an update occurred first | Mutation boundary failed | Move authorization before the protected write |
| List excludes foreign row; total includes it | Aggregate leakage | Scope count and result construction consistently |
| Fresh grant removal denies list but cached detail returns old content | Delivery freshness gap | Inspect cache key, invalidation and final enforcement |
Keep expected, observed, and not exercised as different columns in a real record. Do not prefill observed outcomes from source reasoning. A planned manual comparison is valuable before implementation, but it becomes runtime evidence only when an authorized operator records what the relevant build actually did.
The small matrix can then expand intelligently. Add an export only if it is a distinct delivery path; add a second role when it changes the rule; add a permission change when cache or session state could change the result. This is more informative than sending many arbitrary IDs through a route whose legitimate path never worked. OWASP's authorization regression guidance provides the wider principal/resource/action structure.
Repair the shared rule and keep projections distinct
If Cedar Desk's detail handler lacks an object check, make it load the invoice within a trusted account-and-capability scope or perform an equivalent server-side authorization decision against the persisted invoice owner before returning anything. Centralize the decision logic where useful, but keep enforcement at every delivery route. A shared helper that accepts accountId from an untrusted parameter without verifying membership simply shares the flaw.
Use separate projections for list and detail when the policy calls for them. A list may show number, due date and amount while detail includes an address. That difference is deliberate only if both are permitted for the caller. OWASP's output-handling guidance warns that a resource decision must be carried through to the actual fields released.
After the change, compare the same matrix at the new revision. Confirm the legitimate Ava path still works and both denied paths fail. If you only reviewed source, report a source-supported correction. If a gateway, cache, replica or export worker was outside scope, state that and assign a follow-up. A missing response in a partial run is not proof of a secure deny; distinguish blocked, untested and explicitly denied.
Vary one policy input at a time
Cedar Desk's matrix is most informative when the denied request differs from a permitted request in a known way. If Bea's foreign-object request uses an expired credential, a different API version, and an invalid invoice ID, the resulting error cannot isolate object authorization. Keep the method, route shape, build, and valid object state comparable while changing the caller's entitlement or the requested object's account.
Start with Ava's valid invoice and working billing-read credential. Then inspect the same object as Ben, who shares the account but lacks billing-read. That comparison targets the action grant. Inspect it as Bea with a valid Cedar-B credential to target the tenant boundary. Finally, remove Ava's grant and make a fresh request to target permission freshness. Each row answers a different policy question; none needs real customer data.
A fixture record should contain the intended ownership and grant state, not just convenient usernames. If someone changes Ben's role during the review, the expected result changes. Record the relevant policy version or an equivalent timestamped state snapshot so a later reviewer can understand the observation. The article's invented results do not supply that runtime evidence.
Check the object reached through a saved link
A list page can give Ava a permitted link at one time while her rights change before she opens it. Review the detail route as a new operation. It must establish current access to the stored invoice, rather than treating the existence of the link or a prior list response as proof of authorization.
The same applies to list cursors and cached pages. A stale browser can retain information already delivered; that is different from the server returning protected content on a new request after removal. Capture which event occurred. If the browser simply displays its earlier copy, the observation does not prove a new server disclosure. If the detail endpoint returns the old body from a shared cache without checking the new policy, it is a fresh delivery path to inspect.
This distinction improves the repair. A UI refresh may make a stale page disappear while leaving the server flaw intact. A server authorization fix may correctly deny future delivery while being unable to retract the old browser copy. The acceptance record should name both the intended server boundary and any UI behavior the product separately wants.
Keep errors connected to their cause
For each denied row, ask whether the relevant policy rejected the operation before protected content or a mutation escaped. A generic error caused by a database outage is not an authorization result. A correct denial followed by a notification that includes the forbidden invoice number is also incomplete protection of the output path.
The final record can remain compact: expected policy decision, observed response fields, relevant side effect, build, and any unreviewed delivery path. That is enough to explain why list and detail now implement the same object rule while preserving their different permitted projections.
Next action: pick one sensitive object type and write the seven-row matrix before editing its handlers. For the parent-chain check behind a detail lookup, read how to verify parent and child ownership. For a broader policy design, continue with least-privilege developer finding views.