On this page
Broken object level authorization is the first entry in the OWASP API Security Top 10, and scanners find almost none of it, because a scanner cannot know that invoice 10432 belongs to someone else. Testing it is methodical rather than clever: take every request one user can make and replay it as another user.
You need at least two accounts in the same role, one account in each other role, and two tenants if the product is multi-tenant. Without a second user, authorization testing is guessing. Every account, tenant and object on this page belongs to an application named in the scope letter and was created for the test.
What to check first
- Every object identifier in paths, query strings, bodies and headers: numbers, UUIDs, slugs, file names.
- Routes the API serves that the interface hides, taken from the JavaScript bundle during the first hour.
- Write and delete methods, not only reads.
- Exports, reports, attachments, webhooks and share links, which often skip the checks the main screens have.
- Where the tenant comes from: the session, or something the client sends.
IDOR: swap the object identifier
# object owned by user A, requested with user B's token
curl -s -w '\n%{http_code} %{size_download}\n' \
-H "Authorization: Bearer $TOKEN_B" https://app.example.com/api/invoices/10432
If B gets A's invoice, that is the finding. UUIDs do not prevent it; they turn up in emails, logs, browser history and share links. We prove the issue with one or two records and stop. We never walk a full table to show scale; the request pattern shows it for us.
Fix. Check ownership in the query, not after it. Load the object with both its ID and the owner or tenant taken from the session, and return 404 when no row matches, so the response does not confirm that the object exists.
HTTP method tampering
# project 77 was created in user A's account for this test
for m in GET PUT PATCH DELETE; do
printf '%s ' "$m"
curl -s -o /dev/null -w '%{http_code}\n' -X "$m" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
-d '{"name":"authz-test-b"}' https://app.example.com/api/projects/77
done
Reads are often checked and writes are not, because they were built at different times by different people. Run this only against objects created for the test. Every successful write is real: a PUT can replace the whole object with the fields you sent, and a DELETE removes it. The marker value in the body makes a successful write easy to spot when we read the object back as A.
Fix. One authorization policy per resource, applied by middleware to every method and deny by default. A route with no policy attached should fail to start, not fail open.
Call the admin route as a user
curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $TOKEN_USER" \
https://app.example.com/api/admin/users
Fix. Enforce roles on the server for every admin route. Hiding the button is a usability decision, not a control.
Mass assignment
curl -s -X PATCH -H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
-d "{\"display_name\":\"tester b\",\"role\":\"admin\",\"org_id\":\"$TEST_TENANT_ID\",\"email_verified\":true}" \
https://app.example.com/api/me
We add fields the form never sends and then read the profile back. TEST_TENANT_ID is a second tenant created for the test, never a guessed number: a write that succeeds against a guessed tenant ID could move the test account into a real customer's organization. Frameworks that bind the request body straight onto the model will write whatever arrives.
Fix. Allowlist the writable fields for each role and endpoint. Never bind a request body directly to a database model.
Tenant boundaries
Multi-tenant products carry the tenant somewhere: a path segment, a subdomain, or a header such as X-Org-Id. We swap it for the second tenant's value on every request type, including file downloads and background exports, which often run without the request's context.
Fix. Derive the tenant from the session. If a client-supplied tenant value is needed, check it against the user's memberships on every request. Add a test that requests another tenant's object and expects 404, and keep it in CI.
Automate the replay
Autorize, a Burp extension, replays every proxied request with a second user's session and with no session, and flags responses that match. It finds candidates. We confirm each one by hand, because a 200 with an empty list is not a finding and a 403 with data in the body is.
GraphQL needs the same treatment per resolver. Where the schema exposes a node(id:) query, one query reaches objects of every type, so each type's resolver needs its own check.
Fix. Put authorization in the resolver or data layer for each type, not only on the top-level query.
Reporting it
Show both accounts, the exact request, and a response with the other user's data redacted. The writing a finding page uses an IDOR as its worked example. The fix we recommend is always a change in how the data is loaded, because a fix that blocks one endpoint leaves its siblings open.
What the defender sees
Authorization tests look like normal use: one logged-in user making ordinary requests. The signals are in the application's own logs, if it writes them: 403 and 404 responses on object IDs the user has never been shown, one account reading objects in a sequence, or a burst of requests to admin routes from a non-admin. Most applications log none of that by default, and that goes in the report.