What you need and what you will have at the end
You need your own app running in a development or staging environment, two test accounts you created (user A and user B), a third account with admin rights if your app has an admin area, a browser with developer tools, and curl. Do not run this against production data you cannot afford to change, and never against an app you do not own.
At the end you will have a table with one row per endpoint and one column per test. Each cell says whether the server blocked the request. Any cell that says it did not is a finding with a named fix.
Step 1: Create the two accounts and seed data
Sign up as user A and user B using two different emails. Log in to each in a separate browser profile so the sessions never mix. As user A, create one of everything your app stores: an order, a document, a project, a message, a profile change. Do the same as user B. Note the identifier of each record, usually a number or UUID visible in the URL or in the network response.
Now you have known-owned records for each account. The whole test is asking whether B can touch A's records and A can touch B's.
Step 2: Capture user A's requests
In the browser profile for user A, open developer tools and go to the Network tab. Use every feature of the app once: view a record, edit it, delete it, list records, upload a file. Filter to Fetch/XHR. For each request that carries a record identifier, right-click it and choose Copy as cURL.
Paste each into a text file. Every request shows how the app authenticates, either a cookie header or an Authorization header with a bearer token. Save user B's token or cookie the same way from the other profile.
export BASE="https://staging.example.com"
export TOKEN_A="paste-user-a-token"
export TOKEN_B="paste-user-b-token"
export ID_A="record-id-owned-by-a"
export ID_B="record-id-owned-by-b"Step 3: Test read access
Replay the read request for A's record with B's credentials. A correct server answers 403 or 404 and returns none of A's data. A vulnerable server answers 200 with A's record.
Also try the request with no credentials at all. It should answer 401.
curl -i "$BASE/api/orders/$ID_A" -H "Authorization: Bearer $TOKEN_A"
curl -i "$BASE/api/orders/$ID_A" -H "Authorization: Bearer $TOKEN_B"
curl -i "$BASE/api/orders/$ID_A"Step 4: Test update and delete
Replace the token in A's update and delete requests with B's. Use test records you are happy to lose. The dangerous result is a 200 or 204, and you should confirm by fetching the record as A afterward to see whether it changed or vanished.
Also test mass assignment on update. Add a field the UI never sends, such as an owner or role field, and see whether the server accepts it. A user changing their own role or a record's owner id is the same class of bug.
curl -i -X PATCH "$BASE/api/orders/$ID_A" \
-H "Authorization: Bearer $TOKEN_B" \
-H "Content-Type: application/json" \
-d '{"note":"written by B"}'
curl -i -X DELETE "$BASE/api/orders/$ID_A" \
-H "Authorization: Bearer $TOKEN_B"
curl -i -X PATCH "$BASE/api/profile" \
-H "Authorization: Bearer $TOKEN_B" \
-H "Content-Type: application/json" \
-d '{"role":"admin"}'Step 5: Test list endpoints and search
List endpoints are easy to miss because they have no identifier to swap. Call each list as user B and check whether any of A's records appear. Look at filters too: query parameters such as user id, owner or email can override the server's own scoping if the server trusts them.
curl -s "$BASE/api/orders" -H "Authorization: Bearer $TOKEN_B"
curl -s "$BASE/api/orders?user_id=USER_A_ID" -H "Authorization: Bearer $TOKEN_B"
curl -s "$BASE/api/orders?limit=1000" -H "Authorization: Bearer $TOKEN_B"Step 6: Test admin routes
Find every admin route: check the frontend code for paths with admin in them, and compare against the routes your framework lists. Call each with a normal user's token, both for reading and for writing. Hiding an admin link in the interface protects nothing, because the server has to refuse the request.
If a normal user gets anything other than 401 or 403, the route trusts the client to decide who is an admin.
curl -i "$BASE/api/admin/users" -H "Authorization: Bearer $TOKEN_B"
curl -i -X POST "$BASE/api/admin/users/$ID_B/promote" -H "Authorization: Bearer $TOKEN_B"Step 7: Record the results and fix
Build one table and fill it as you go. Mark each cell blocked or leaks. Fix from the top of the list, and fix the server, not the interface.
| Endpoint | Read as B | Update as B | Delete as B | No auth | Fix |
|---|---|---|---|---|---|
| GET/PATCH/DELETE /api/orders/:id | leaks | leaks | blocked | blocked | Add owner check in handler |
| GET /api/orders | blocked | n/a | n/a | blocked | None |
| GET /api/admin/users | leaks | n/a | n/a | blocked | Add role check |
Fixing what fails
- Load the record by id and compare its owner to the authenticated user's id taken from the verified session, never from the request body or query string.
- Return 404 rather than 403 for records the user does not own if you do not want to confirm the record exists.
- Build the query so it is scoped by owner, for example where id equals the id and owner equals the session user, so a missed check cannot happen.
- Check the role on the server for every admin route, using a role stored on the server, not a value the client sends.
- If you use Supabase or another database exposed to the client, enforce the same rules with Row Level Security policies.
Common mistakes
How to verify
The pass condition is a results table where every cross-account cell is blocked: no read, update or delete of A's records with B's credentials, every list scoped to the caller, every admin route refusing a normal user, and every request without credentials refused. Re-run the failed rows after each fix and confirm A's data is unchanged.
Keep it working
Keep the curl commands in a script in your repository and re-run it whenever you add a route, change auth, or let an AI tool regenerate an API layer. Add a row for every new endpoint the moment it exists.
Frequently asked questions
What is IDOR?
IDOR stands for insecure direct object reference. It happens when an app uses an identifier from the request, such as a record id, to fetch or change data without checking that the caller is allowed to access that particular record. It is one common form of broken access control.
Do UUIDs prevent this?
No. Hard-to-guess identifiers only make guessing harder. They leak through lists, shared links, logs and emails, and once someone has one, only a server-side ownership check stops access. Treat unguessable ids as a bonus, never as the protection.
Can I test my production app?
Prefer staging. If you must test production, use only accounts and records you created, avoid destructive requests on real data, and stay on your own app. Never run these tests against any system you do not own or have explicit permission to test.
Is this enough to call my app secure?
No. It covers one important class of bugs: who can access what. It does not cover injection, secrets, dependencies or configuration. Treat it as one repeatable check in a wider routine.