IDOR: reading other users’ invoices by changing an ID
An API that authenticates the request but never checks who owns the object it returns.
GET /api/invoices/1042 returns your invoice. GET /api/invoices/1041 should return a 404. In a lot of APIs it returns someone else’s invoice. This is an IDOR (insecure direct object reference), the most common form of broken access control, which is first in the OWASP Top 10.
Your invoice is 1042. Change the number.
{
"id": 1042,
"customer": "Alice Durand",
"email": "alice@example.com",
"address": "2 place du Marché, Rennes",
"iban": "FR76 1430 •••• 0377",
"total": "EUR 420.00"
} func getInvoice(w http.ResponseWriter, r *http.Request) {
inv := db.Invoice(r.PathValue("id"))
// no check: is this invoice yours?
json.NewEncoder(w).Encode(inv)
}The bug
The handler requires a valid session, then loads the invoice by ID alone:
inv := db.Invoice(r.PathValue("id"))
json.NewEncoder(w).Encode(inv) Authentication answers “who is this?“. Nothing answers “can this user read this object?“. The frontend only links to the user’s own invoices, so the bug never shows up in normal use or in UI-driven tests.
Where to look
- Any parameter that references an object: path, query string, JSON body, GraphQL arguments.
- Write methods.
PUTandDELETEare often less protected thanGET. - Exports, file downloads, signed URL generation, endpoints used only by the mobile app.
- Sequential IDs make enumeration trivial. UUIDs only slow it down: they leak through URLs, emails and logs, and they are not an access control.
Testing
Two accounts, A and B. Record A’s traffic through a proxy, replay each request with B’s session, and vary IDs and methods. Any of A’s data in a response is a finding. Scanners rarely catch this: a 200 with a well-formed invoice looks correct unless you know whose invoice it is.
Fix
Scope the query to the caller, so a forgotten check returns nothing instead of another user’s data:
SELECT * FROM invoices WHERE id = $1 AND owner_id = $2 Return 404 rather than 403 for objects the caller cannot access, so the response does not confirm that they exist. Add tests for the negative case: B requesting A’s object must fail, for every method.
I test web apps, APIs and LLM integrations. Contact.
Next article → JWT "alg": "none": forging an admin token