Authorization
Enforce object-level authorization
Check the caller’s relationship to every requested object instead of accepting an object identifier as proof of access.
An object ID tells the server what to load. It says nothing about who is allowed to load it.
Miss that distinction and you get broken object level authorization, or BOLA. You may know it by its older name, IDOR. It sits at number one in the OWASP API Security Top 10 because it keeps working: change one identifier in a URL, read someone else’s data.
Put ownership in the query
The fix is to make the ownership check part of the query itself. Take the identity from trusted authentication state, then constrain the lookup by owner, tenant, membership, or policy:
SELECT id, total
FROM invoices
WHERE id = ? AND account_id = ?
The account_id value comes from the session. Never from the request. If the row belongs to someone else, the query returns nothing and the API returns a plain 404.
Think about it this way. Customer 42 can be signed in, with a valid token, doing nothing suspicious, and still request customer 43’s invoice. Authentication passed. Authorization is a separate question, and this query answers it.
A word on random-looking IDs. UUIDs make guessing harder, and that’s good. But they never replace the ownership condition. UUIDs leak through logs, referrer headers, and support tickets all the time.
Test with two valid identities
You can’t test this with one account. You need two real accounts, both authenticated, each trying to reach the other’s data:
# customer 42's token requesting customer 43's invoice
curl -i https://api.example.com/invoices/inv_9f3c2a \
-H "Authorization: Bearer $TOKEN_CUSTOMER_42"
# HTTP/1.1 404 Not Found <- the ownership check is working
Now repeat for update, delete, export, and every nested route. A team that protects GET /invoices/:id but forgets the DELETE handler has decorated the problem, not fixed it.
Check indirect paths
Nested resources are where this bug survives longest. An attachment, a comment, an export that lives under an invoice. The handler checks the parent invoice, feels safe, and loads the child by its ID alone:
curl -i https://api.example.com/invoices/inv_own1/attachments/att_foreign \
-H "Authorization: Bearer $TOKEN_CUSTOMER_42"
# must be 404: the attachment belongs to a different invoice
Here inv_own1 really belongs to customer 42. That’s not enough. att_foreign belongs to a different invoice, so the child query needs its own relationship condition, joining back to the authorized parent. Every level of nesting, every time.
Try this on your own API: create invoices for two accounts. With each token, try to read, update, delete, and export the other account’s invoice. Save the responses and the database state, and prove every cross-account attempt failed without touching the target.
Lesson completed