Safeguard
Security Guides•

Does your list endpoint return other users' records? A bulk access test

Single-object checks can pass while list and search endpoints return everyone's data. A short bulk access test with methods, filters and expected results.

Safeguard Security Team
7 min read

Test list and search endpoints as a second user and check that only that user's records come back. Many APIs enforce ownership when fetching one object by ID but forget it on collection routes, exports and search. A single wrong filter can return every customer's data in one call.

Why lists fail where single objects pass

Developers often add the ownership check to the handler that loads one record. Collection handlers are written separately, sometimes by someone else, and may query the table without a user filter. The OWASP Web Security Testing Guide's object level authorization test includes a step for exactly this: check whether list endpoints return data for all users instead of only the authenticated user's data.

The same guide sets the baseline for the whole test: use two accounts. Account A creates resources, and account B attempts to reach them.

The test

  1. Seed account A with several objects of each type, and account B with a different set.
  2. As account B, call every collection route: lists, search, autocomplete, exports, reports and "recent activity" feeds.
  3. Count the results and check each record's owner. Every row should belong to B.
  4. Repeat with paging parameters. Ask for the first page, a very large page size and a late page.
  5. Repeat with filters and sort parameters, since filters can interact with ownership logic in odd ways.
  6. Try each HTTP method the guide lists for object access, GET to read, POST, PUT or PATCH to modify, and DELETE to remove, including bulk versions such as delete-many.
  7. For GraphQL, ask a collection field for items and look for records you do not own.
  8. Interpret the outcome the way the guide does: a 200 with another user's data is the flaw, 401 or 403 means enforcement, and mixed behavior across endpoints suggests incomplete controls.

What to check on each response

CheckExpected
Owner of every returned recordOnly the caller
Total count fieldOnly the caller's total, not the whole table
Next page links and cursorsCannot be edited to cross into other users' data
Export filesSame scope as the list
Error responses on invalid filtersDo not reveal other users' values

The count field is a quiet leak. An API that returns B's rows correctly but a total of the whole table tells an attacker how many customers you have.

Limits matter here too

OWASP's resource consumption guidance recommends server-side validation of parameters that control how many records come back, and enforcing maximum sizes. Test with a page size of one million. If the API tries to comply, you have an authorization question and a resource question at once.

Fixing it

Apply the owner filter in one shared query layer, so each collection route inherits it. Add an automated test that logs in as B and asserts every returned record is B's. Cover exports and search in the same test file, because they are the routes most often forgotten.

Where to run it

This is a good candidate for a regression suite, because the check is mechanical: loop over routes, assert ownership. Run it in CI against a seeded test environment.

Worked example: a document sharing API

Take a document service where users upload files and share them with teammates. Routes: GET /docs/ID, GET /docs, GET /docs/search?q=, GET /docs/export, and DELETE /docs/ID. The team has two accounts, Maya and Noah, in different organizations, each with five documents.

Single object check. As Noah, request Maya's document by ID. The response is 403. Good. The team could stop here and feel fine. They continue.

List route. As Noah, call GET /docs. The response shows ten documents. Five belong to Noah and five to Maya. The list route filters on organization only when a query parameter org is present, and the front end always sends it. Anyone who omits the parameter gets every document in the system.

Total count. Noah calls GET /docs?org=noahs-org&pageSize=2. The two documents returned are Noah's, but the total field says 10. The count comes from a different query with no filter, which tells Noah how many documents exist across the platform.

Search route. GET /docs/search?q=contract as Noah returns a match from Maya's account, with a title and a snippet. The search index was built without tenant filtering.

Export route. GET /docs/export returns a zip of all documents the caller can "see", and it uses the same unfiltered query as search. Noah receives Maya's files.

Paging edge. Noah requests pageSize=100000. The API returns ten documents and takes noticeably longer. There is no upper bound, which is a resource consumption concern, though it did no extra harm with only ten records.

Bulk delete. DELETE /docs?ids=1,2,3 as Noah with some of Maya's IDs returns 200 and a count of deleted items. Two of Maya's documents are gone. The single delete checked ownership, the bulk version did not.

RouteResult as NoahVerdict
GET single document, Maya's403Pass
GET list without org parameterEveryone's documentsFail
Total countWhole tableFail
SearchOther tenant's snippetsFail
ExportOther tenant's filesFail
Bulk deleteDeleted another tenant's documentsFail

Five of six failed, behind a single object check that worked.

The fix

Move the tenant filter into one shared query function and have every route call it. Make the filter mandatory in the function signature, so a route cannot forget it. Compute counts from the same filtered query. Apply the owner check inside the bulk delete for each ID. Cap page size at a sensible maximum.

Tests worth keeping

One test file, logged in as Noah, loops over every collection route and asserts that every returned record, every total, every snippet and every exported file belongs to Noah's organization. Add a Maya-owned canary document with a unique word and assert it never appears in search results for Noah. Run it in CI.

Why this pattern is common

Authorization is easiest to get right in the handler that loads one record, because the record's owner is right there. Collections, counts, search indexes and exports are assembled elsewhere, often later, often by someone who was not part of the original design. Test them as separate features, because that is what they are.

Try it yourself

Open the demo and look at how findings are tied to specific endpoints. Think about listing the six routes above as separate rows, each with its own result for the second account.

Click through it at your own pace, or open it full screen.

Quick check

If object fetch by ID returns 403, are lists safe?

No. They are separate code paths. Test them separately.

Does a UUID in the URL protect a list?

IDs do not matter on a list route, because the server picks the records. If the filter is missing, all of them are returned.

Should counts and totals be treated as sensitive?

They can be. A total across all tenants reveals how many customers or records you hold. Compute every count from the same filtered query as the records.

Sources

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.