Broken Object Level Authorization (BOLA) — long called IDOR — is the vulnerability where the application checks who you are but never checks whether this particular thing belongs to you. The request is authenticated, the session is valid, the endpoint is doing exactly what it was built to do — it's just doing it for someone else's object. GET /api/invoices/1042 returns an invoice, and nothing stops 1043, or 1, or 99999.
It's usually filed as "the top vulnerability" (OWASP API Security has ranked broken object-level authorization at #1 for years), and it is top not because it's clever but because it's unglamorous. No XSS payload, no deserialization gadget, no race condition. Sometimes the entire exploit is changing one number in a URL.
This guide covers what the flaw actually is, why the "it's an internal API" instinct is wrong, how attackers find and enumerate objects, why UUIDs and unguessable IDs are a mitigation but not a fix, the difference from function-level authorization (broken access control), how to fix it in a way that scales to hundreds of endpoints, and how to test for it. The free security scan checks the headers, TLS and cookie posture that surround your API — but IDOR lives inside your business logic, so this one class almost always needs dedicated testing.
Table of contents
- What IDOR actually is
- Why it keeps happening
- How attackers enumerate objects
- "The IDs are unguessable"
- IDOR vs broken function-level authorization
- The fix that scales
- How to test for it
- Frequently asked questions
What IDOR actually is
Authorization is two questions, and most code only asks the first:
- Authentication / function-level: who is calling, and may they call this endpoint at all?
- Object-level: is this specific invoice, file, user or order theirs?
Broken function-level authorization looks like a normal user calling PATCH /admin/users/7 and getting 200. IDOR looks like a normal user calling GET /api/invoices/7 and getting 200 — but invoice 7 isn't theirs. The endpoint is correctly restricted to authenticated users; the object was never checked. That distinction is why an app can pass a coarse "can this user reach the API" test and still leak an entire customer database.
The canonical vulnerable handler looks like this:
func (h *InvoiceHandler) Get(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(mux.Vars(r)["id"]) // 2: from the URL
inv := h.repo.FindByID(id) // 3: by ID alone
// currentUser comes from the session...
// ...but is never compared to inv.UserID <-- the bug
json.NewEncoder(w).Encode(inv)
}
Note what's not wrong here: authentication happened, the database query is parameterized (no SQL injection), and the response is JSON. The single missing line — scoping the query by the authenticated user's identity — is the whole vulnerability. Fix the injection, add a WAF, rate-limit the endpoint, and it stays exploitable.
Why it keeps happening
A few patterns produce most real-world cases:
- Trusting the caller. The parameter in the URL is treated as a selector rather than an untrusted input that must be authorized. This is the mental root cause.
- Global vs scoped queries. The repository exposes
FindByIDand the handler uses it because it exists and "works". A scopedFindByIDForUser(id, userID)never gets written. - Multi-tenant by header. Some apps scope by a
X-Tenant-Idheader the client controls — that's not authorization, it's a suggestion. - Object type confused with object ownership. Validating that a record exists and is the right kind is not the same as validating it belongs to the caller.
- Admin paths. Features behind
/adminare the most commonly unguarded by object checks, because developers assume "only admins get here" — until one admin role is over-provisioned or a support login is misused.
The uncomfortable truth: this class of bug doesn't announce itself. There's no crash, no log line, no stack trace. The requests all succeed — they're just succeeding for the wrong person. That is precisely why it survives code review and penetration tests scoped to a single test account: testing with one account can never find IDOR, because you need a second account to compare against.
How attackers enumerate objects
Given a working endpoint, finding the rest of the data is usually mechanical:
- Find a parameter that returns something. An invoice page, a file URL, an avatar path, a profile page. Any value that varies per user.
- Vary it. Increment it.
1042→1043→1044. Or jump: try1,0,-1,999999,2,3. - Classify the response. A
200with someone else's data is confirmed IDOR. A403/404means it was checked. Note that some apps return200with a generic body — that's not a leak, and scanners that flag it are wrong; distinguish "other user's data returned" from "request rejected." - Enumerate in bulk. IDs are sequential, so this is fast and embarrassingly parallel. A single authenticated session can walk millions of IDs, exporting a full customer dataset — names, emails, addresses, order history — with no rate-limit trigger, because from the server's view every request is a perfectly ordinary 200.
The blast radius of a single sequential ID is the entire table. An attacker who found one reachable invoice endpoint has, in practice, found read access to the whole schema one column at a time.
"The IDs are unguessable"
Switching from id=1042 to id=8f14e45f-ceea-467a-9d2f-… (a UUID) raises the bar: an attacker can no longer enumerate by incrementing. But it does not fix the bug:
- UUIDs leak. They show up in URLs people share, in Referer headers, in logs, in support tickets, in browser history synced across devices, in API responses that return other objects' IDs (order history, "users in this team").
- Authorization is still supposed to happen. Relying on unguessability is security-by-obscurity applied to one field; the server still doesn't ask "is this yours?"
- It converts a data breach into a mystery. With sequential IDs an attack is loud and obvious. With unguessable-but-unauthorized IDs you get a silent leak with no obvious trail — arguably worse to detect.
Use UUIDs (they're good hygiene and they do blunt enumeration) and authorize every object access. One is not a substitute for the other.
IDOR vs broken function-level authorization
These get conflated but they're different bugs with different fixes:
| Broken function-level authz | Broken object-level authz (IDOR) | |
|---|---|---|
| Question failed | May you call this endpoint? | Is this object yours? |
| Trigger | Calling an admin endpoint as a normal user | Calling your own endpoint with someone else's ID |
| Fix | Role checks on the route/handler | Ownership check on every object access |
An app can be perfect at one and terrible at the other. Your users' endpoints being role-gated says nothing about whether user A can read user B's invoice.
The fix that scales
The reliable pattern is to make unauthorized access impossible to express rather than something each developer must remember to check:
- Scope the query by owner at the data layer. Prefer
WHERE id = ? AND user_id = ?over fetching then comparing in the handler. The unscoped finder simply stops existing. - Derive the acting user from the session/token only — never from a request parameter or client-controlled header.
- Use one consistent guard. A helper/policy layer (
can(user, object)or a repository method per object type) that every handler goes through. Access control that lives only in handler code is access control someone will forget. - Return 404, not 403, when the object doesn't belong to the caller — 403 confirms "this exists but isn't yours," which is itself a small leak and an enumeration oracle.
- Apply it to writes too. Creating an object on behalf of another user (a body containing
user_id) is the same bug in a different position.
Pseudocode for the fixed handler:
func (h *InvoiceHandler) Get(w http.ResponseWriter, r *http.Request) {
user := session.User(r) // trusted identity
id, _ := strconv.Atoi(mux.Vars(r)["id"]) // untrusted selector
inv := h.repo.FindByIDForUser(id, user.ID) // owner-scoped query
if inv == nil {
http.NotFound(w, r) // not found OR not yours
return
}
json.NewEncoder(w).Encode(inv)
}
The difference is that the unsafe version is still expressible; the fixed version is not.
How to test for it
- Two accounts, always. Create account A and account B, each with an object. From A's session, request B's object by ID. Non-404 → IDOR. This is the single highest-value test you can run, and most teams skip it precisely because one account is what they have.
- Automate per endpoint. For any endpoint with an object parameter, replay it with another tenant's ID and assert the response is 404 (or an empty, non-leaking body).
- Check the negative space. For writes, try setting
user_id/owner_idin the request body to another user and assert it's ignored or rejected. - Watch mass assignment. Endpoints that bind a whole request body to a model can set ownership fields you didn't intend to expose — another form of the same bug.
- Regress it in CI. A generic test that, for each
{object}endpoint, requests a random other user's object and fails on a non-404 keeps the whole class from coming back.
Automated scanners (including ours) can reliably detect the surface — "this endpoint takes an object ID" — but whether that ID is authorized requires business-logic knowledge. For IDOR specifically, a thoughtful two-account manual test beats any scanner.
Frequently asked questions
Is IDOR the same as "insecure direct object reference"?
Yes — BOLA is the current OWASP name for the same class of bug: referencing an internal object by a guessable or otherwise obtainable identifier without confirming the caller is allowed to access it.
We use UUIDs — are we still vulnerable?
Possibly. UUIDs raise enumeration cost but don't fix the missing ownership check, and they leak through logs, URLs and other objects' fields. Authorize every access regardless of how unguessable the ID is.
How do I find these endpoints in my own codebase?
Grep for handlers that take an ID from the URL, body or query and then call a repository lookup by that ID alone (FindByID, WHERE id = ?, db.First(&x, id)), especially where the result is returned without a visible ownership comparison. Then confirm with the two-account test.
Does a WAF or rate limiter fix it?
No. Every request is a legitimate-looking authenticated 200; there's no malicious payload to match and nothing anomalous to rate-limit. Authorization has to happen in your application logic.
What's the difference between BOLA and CSRF?
CSRF forges a request from a victim's browser using their session. BOLA uses the attacker's own valid session to read/write objects they don't own. They're independent — you can be vulnerable to either without the other.
Should a failed ownership check return 403 or 404?
Usually 404. A 403 ("exists but forbidden") confirms the object exists, which helps an attacker enumerate valid IDs. Both are acceptable if you're consistent, but 404 leaks less.
We only have one API client — is it exposed?
Exposure isn't about who calls it; internal networks get breached, tokens leak, former employees and contractors keep credentials, and mobile clients can be instrumented. Treat every API as internet-facing, and don't rely on "internal only" as a control.
Remote Daemon