Authorize a collection by restricting what the query can return, not by checking items after you have loaded everything. Either filter in the data layer with the caller's tenant and permission predicates, or fetch a bounded set and check each item, and apply the same rule to counts, exports and aggregates.
Why lists get missed
Teams write a careful check for the single-document route and then ship GET /documents?q=report with a bare query on the collection. The OWASP Authorization Decisions and Output Handling Cheat Sheet says the restriction must cover every data path: list, search, export, count, aggregate and direct-object reads. An authorized list does not authorize a later update or delete either, so those still need their own checks.
A list endpoint is also where one bug leaks the most. A missing filter on a single-object route exposes one record per guess. On a list route it can expose a table.
Three ways to enforce it
The same cheat sheet describes three approaches. Pick by what your policy engine and data store really support.
| Approach | How it works | Best for |
|---|---|---|
| Check each candidate | Retrieve a bounded set, evaluate each item, drop denied ones | Small sets, no filter interface |
| Fetch authorized IDs | The policy engine returns identifiers the caller may access, and you query only those | When the engine can list permitted objects |
| Apply an authorization filter | The engine returns a predicate or query plan that the data layer enforces | Large queryable collections |
Check each candidate
Simple and flexible, but you have to bound the candidate set and keep it inside the trusted service until checks finish. Excluded and unresolved items must never leave. Pagination gets awkward, since a page of 50 may shrink to 12 after filtering.
Fetch authorized identifiers
The guidance warns that result limits and deadlines may apply. A truncated authorized subset can be shown as partial if every returned item is permitted, but it cannot prove the complete set. Never read an empty or incomplete list as permission to drop the restriction.
Apply a filter
The data layer enforces a predicate during retrieval. Use a maintained adapter for the exact engine and store, bind filter values as parameters, allow-list field names and operators, and reject translations you cannot represent. Do not silently omit an expression that failed to translate, and do not run a returned debug string as a query.
Rules that apply to all three
- Intersect predicates with logical AND. Authorization narrows the tenant and business filters, it never replaces them.
- Fail closed. If the policy engine errors or times out, deny. A missing filter is not an unrestricted result.
- Cover counts and totals. A "1,204 results" line leaks information about rows the caller cannot see.
- Cache only for the same subject, action, scope and tenant, within an approved freshness limit.
- Test with permitted and denied resources, tenant boundaries and incomplete results.
A quick way to test
Create two users with different access and seed records that only one may see. Call every list, search, export and count route as the other user and assert the response contains none of those records and the total matches the visible set. Run it again after a permission change.
Try it yourself
Click through it at your own pace, or open it full screen. Look at how the scan treats collection endpoints next to single-object ones.
Safeguard supports dependency scanning, policies and gates for pull requests, so changes that touch your query or authorization libraries can be held for review. Try the free scan at gold.safeguard.sh/scan.
Quick check
Is filtering results in the UI enough?
No. The server must withhold the data. Anything sent to the browser can be read, whatever the page chooses to display.
Do export and CSV download routes need the same rule?
Yes. Exports are collections too, and they often run in background jobs that lose the caller's context unless you carry it through.
Sources
Last reviewed: 19 October 2026.