← articles

web · API · access control

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.

Access control live demo
acme-portal · REST API
GET /api/invoices/
Authorization: Bearer eyJhbGciOi… ; logged in as alice

Your invoice is 1042. Change the number.

HTTP/1.1 200 OK
{
  "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)
}
Simulated. Runs entirely in your browser.

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. PUT and DELETE are often less protected than GET.
  • 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.