Here's a magic trick that requires zero tools, zero exploits, and zero technical skill beyond knowing how to edit a number in a URL bar: log into an app, find an endpoint like GET /api/invoices/1042, change it to GET /api/invoices/1043, and hit enter. If you just read a stranger's invoice, you've personally discovered Broken Object Level Authorization — the bug that consistently tops the OWASP API Security Top 10, and the one that keeps showing up in breach reports for companies whose security teams swear they "check auth on every endpoint."
They probably do check auth. That's exactly the problem BOLA exploits.
Authentication is not authorization, and APIs forget this constantly
Every API in the world checks authentication — is this a valid, logged-in user with a real token? Most frameworks make that nearly impossible to skip; middleware rejects missing or invalid tokens before your route handler even runs.
Authorization is a completely different question: is this specific, valid, logged-in user allowed to touch this specific object? That check doesn't come free from any framework. Nobody ships middleware that automatically knows invoice 1043 belongs to a different tenant than the one holding the token. A developer has to write that check, by hand, on every single object-fetching endpoint — and on a REST API with hundreds of routes, someone always forgets one.
// Passes authentication. Fails authorization. Ships anyway.
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice) return res.status(404).json({ error: 'not found' });
res.json(invoice); // never checked invoice.userId === req.user.id
});
requireAuth did its job perfectly. It confirmed the requester is a real user with a valid session. It said nothing — because it can't say anything — about whether that user owns invoice 1043. The route works flawlessly in every test where a user only ever requests their own data, which is exactly how it survives QA and shows up in production instead.
Why this bug is everywhere and nowhere on a scan report
BOLA doesn't look like a vulnerability to a scanner. There's no malformed input, no injection payload, no stack trace to fingerprint. The request is completely well-formed — it's a legitimate, authenticated call to a real endpoint with a valid ID. The only thing wrong with it is a fact your database knows and your route handler never asked: whose ID is it?
That's why BOLA shows up disproportionately in real breach disclosures rather than automated pentest findings. It requires understanding the business logic — "objects belong to users/tenants/orgs, and cross-object access should be denied" — which is precisely the kind of context a fuzzer doesn't have and a bored, curious user absolutely does. This is also why bug bounty programs are flooded with BOLA reports: it's the highest reward-to-effort ratio in the entire vulnerability class. No tooling required, just patience and a sequential ID.
It gets worse with nested and indirect objects, because the "does this belong to the user" question multiplies:
// Two objects, two ownership checks needed. Only one exists.
app.get('/api/projects/:projectId/tasks/:taskId', requireAuth, async (req, res) => {
const task = await db.tasks.findById(req.params.taskId);
// Checked: does this task belong to this project? Maybe.
// Never checked: does this project belong to req.user?
res.json(task);
});
Every layer of nesting is another silent opportunity to check the wrong relationship, or none at all.
The fix: push ownership into the query, not an if-statement after it
The most reliable pattern isn't "fetch the object, then check if the user owns it" — that's a check a future refactor can accidentally delete. It's better to make ownership part of the fetch itself, so there's no unauthorized state to leak in the first place:
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
userId: req.user.id, // ownership is baked into the query, not bolted on after
});
if (!invoice) return res.status(404).json({ error: 'not found' });
res.json(invoice);
});
Notice the response for "exists but isn't yours" and "doesn't exist" is identical — a 404, not a 403. That's deliberate. A 403 confirms the object exists and belongs to someone, which is exactly the kind of enumeration signal you don't want to hand an attacker walking sequential IDs.
For anything beyond a handful of routes, don't rely on every developer remembering to write this by hand — centralize it. A single authorization layer (a policy object, a query scope, a middleware that injects the tenant filter automatically) turns "someone forgot" into "the framework wouldn't let you forget."
Where I've actually watched this happen
At Cubet Techno Labs, the BOLA findings that stick with me weren't in flashy new features — they were in the boring CRUD endpoints nobody reviews twice, usually generated early in a project from a scaffold or an admin-panel builder that assumed a single-tenant world. The moment the product added a second organization, every one of those endpoints quietly became cross-tenant readable, because the scaffold's "find by ID" never had a reason to know about tenancy in the first place. Multi-tenant SaaS is basically BOLA's favorite habitat.
The five-minute test
Open your API in two browser sessions logged in as two different users. Grab an object ID that belongs to user A. While logged in as user B, request it directly — /api/invoices/{A's id}, /api/orders/{A's id}, /api/users/{A's id}/profile. If you get data back instead of a 404, you've found a BOLA, and odds are it isn't the only one in that codebase. This isn't a one-time audit either — every new object-fetching endpoint deserves this exact five-minute test before it merges, because the scaffold that generates your next CRUD route has no idea what a tenant is.
Found a BOLA in the wild, or have a story about the endpoint everyone swore was "internal only"? I'd genuinely like to hear it — find me on GitHub or LinkedIn. And if this made you go check one of your own :id routes right now, good — that instinct is the whole point.