Most "we got hacked" stories in web apps don't start with malware. They start with an endpoint that checks who you are but never checks what you are allowed to touch. The industry name is broken object-level authorization, or IDOR.
This is a short, practical guide to testing whether your app has that flaw — the way we do it on an authorized engagement, with written permission, on systems we are paid to test.
Why it happens so often
Frameworks make authentication easy: drop in a session, gate the route, done. Authorization is the part with no shortcut, because it depends on your own data model. It's the difference between:
// authenticated, not authorized
router.get('/api/invoices/:id', requireLogin, (req, res) =>
db.invoices.findById(req.params.id).then(res.json))
and:
// authenticated and authorized
router.get('/api/invoices/:id', requireLogin, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (invoice.ownerId !== req.user.id) return res.status(404).end();
res.json(invoice);
})
Ten lines of code separate a fine app from a breach notification.
The test, in order
1. Map every object identifier. Query parameters, path segments, JSON body fields, hidden form inputs, GraphQL node IDs, S3 keys, signed-URL tokens. If a value selects a record, it's a target.
2. Create two accounts you own. Account A owns the data, account B tries to reach it. Never test with real user data — that's the difference between a test and an incident.
3. Replay with B's session. Capture A's request in your proxy, drop the session cookie and token, and send it again as B. Look at the status code and the body: a 200 with {"error":"forbidden"} is not a fix, and a 403 that still leaks the record name in the message is still a leak.
4. Try identifier shapes. Sequential IDs (1001 to 1002), UUIDs harvested from list endpoints (they are never as secret as people think), slug variations, and nested resources (/users/7/invoices/99 where the check is on 7 only).
5. Test state changes, not just reads. PUT, PATCH, DELETE, role fields, isAdmin, price, quantity, and the email or phone on an account. Reads leak data; writes destroy it.
6. Check the edges. Export endpoints, PDF generators, CSV downloads, share links, webhooks, image proxies, and anything that takes a URL. These are where object checks go missing.
The evidence that makes a finding useful
A screenshot of Postman is not a report. Record, for each issue:
- the exact request and response pair, with both accounts' identities redacted consistently;
- the impact in business terms ("any authenticated user can read any other tenant's invoices" beats "IDOR on /api/invoices");
- a reproduction path a developer can follow in under five minutes;
- the fix, at the level of the check that was missing, not "add authorization".
If you cannot demonstrate the impact with your own two accounts, you have a hypothesis, not a finding.
The legal line, because this matters
Testing someone else's system without permission is a crime in most jurisdictions, whatever the intent. Before a single request goes out you want: written scope (domains, IPs, accounts), the testing window, prohibited techniques, an emergency contact, and a definition of what happens if you find something critical.
If you are testing your own product, write the same document anyway. It forces the team to agree on scope, and it is the thing that protects you when a test knocks over production.
If you would rather not do it yourself
This is the work we do for a living under signed authorization: scoping, testing, evidence, and a report your developers can act on. You can see how we structure an engagement at cyberlords.io/hire-a-hacker, and if you only want to read up first there is a longer piece on staying lawful while doing this.
One closing note for developers: the fix is almost never a WAF rule. Centralize the ownership check next to the query, test it in CI with two seeded accounts, and treat a missing check as a failing test rather than a code review comment.
Top comments (0)