Broken Object Level Authorization (BOLA, also called IDOR) sits at number one on the OWASP API Security Top 10. Not because it is clever. Because it is everywhere, and because it looks exactly like a legitimate request.
Here is the whole bug in one sentence: can user A load user B's data by changing an ID? If yes, you have it. Let me show you how it ships, how to fix it, and why the fix is easier to get wrong than you think.
The vulnerable endpoint
This looks fine in review. It has auth. It has a database lookup. It ships.
// VULNERABLE: authenticated, but no ownership check
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findById(req.params.id);
if (!invoice) return res.status(404).send("not found");
res.json(invoice);
});
Spot the hole. requireAuth confirms the caller is someone. It never confirms the caller owns this invoice. So any logged-in user does this:
# I am user A. This is my invoice:
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/api/invoices/1001
# ...and this is user B's, which I should never see:
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/api/invoices/1002
The request is perfectly well-formed. Valid token, valid route, valid ID. A signature WAF waves it through because there is nothing to match on. Nothing about GET /api/invoices/1002 is "bad" in isolation. It is only bad for this caller, and the pattern-matching layer has no idea who owns what.
The fix: check ownership server-side, always
The fix is to make ownership part of the query, not an afterthought.
// FIXED: the query itself is scoped to the caller
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findOne({
id: req.params.id,
ownerId: req.user.id, // <-- ownership is a WHERE clause, not an if-statement
});
if (!invoice) return res.status(404).send("not found");
res.json(invoice);
});
Two things worth calling out:
-
Scope the query, don't filter after.
findOne({ id, ownerId })is safer thanfindById(id)followed byif (invoice.ownerId !== req.user.id), because the second form leaks existence through timing and is easy to forget when someone refactors. - Return 404, not 403. A 403 confirms the object exists, which hands an attacker an enumeration oracle. Deny by pretending it isn't there.
For nested resources, the same rule applies at every level. GET /api/orgs/:orgId/projects/:projectId needs to prove the caller is in orgId and that projectId belongs to orgId. Miss either check and you are back to BOLA.
Why code review misses this
If the fix is that mechanical, why is BOLA the number one API vuln? Because the failure is an absence, and absences don't show up in a diff.
- The vulnerable version has auth. Reviewers see
requireAuthand their guard drops. - Ownership checks are per-object and per-route. One new endpoint, one forgotten
ownerId, and you are exposed. There is no single place to audit. - ORMs make the unsafe path the short path.
findById(id)is one call. The safe version is more typing, so under deadline it loses. - It scales with your route count. A 300-endpoint API needs 300 correct ownership checks, and "correct 299 times" is still vulnerable.
You can catch a lot with tests. Write an authz test per resource that asserts user A gets a 404 on user B's object:
test("user A cannot read user B's invoice", async () => {
const res = await request(app)
.get(`/api/invoices/${userB.invoiceId}`)
.set("Authorization", `Bearer ${userA.token}`);
expect(res.status).toBe(404);
});
Do this. It is the highest-value security test most APIs don't have. But be honest about the ceiling: you can only test the endpoints you remember to test, and BOLA lives in the endpoint you forgot.
How a positive-security layer helps
Tests and review both depend on a human remembering to cover a route. A baseline layer doesn't. It learns the actual access pattern from live traffic: user A reads user A's objects, org members read their org's projects, and so on. When a request breaks that pattern, one caller reaching for an object outside everything they have ever legitimately touched, it flags or blocks it, even on the endpoint nobody wrote a test for.
That is the class of bug it is built for: the request that is technically valid but contextually wrong, which is exactly what signature tools miss. It ships in observe mode so it learns your real ownership graph before it enforces, and it is fail-open so a protection outage never becomes an app outage.
With Autogon Shield:
app.use(shield({ token: process.env.AUTOGON_TOKEN, mode: "observe" }));
// learns that user A never touches user B's records, then blocks the moment it happens
Fix ownership in your code, that is non-negotiable. Then put a baseline over the whole surface so the one route you missed is still covered by "normal gets through, everything else doesn't." See it at autogon.ai.
Sources
- OWASP API Security Top 10 (BOLA is #1): https://owasp.org/API-Security/
- Average breach cost $4.88M, 292 days to detect (IBM 2024): https://newsroom.ibm.com/2024-07-30-ibm-report-escalating-data-breach-disruption-pushes-costs-to-new-highs
- Autogon: https://www.autogon.ai/
Top comments (0)