A fintech startup shipped their API in February. By March, someone had downloaded 4.2 million user records. No SQL injection, no XSS, no zero-day exploit. They just changed /api/users/1042 to /api/users/1043. And then kept going.
Broken Object Level Authorization — BOLA, or what some people still call IDOR — sits at the very top of the OWASP API Security Top 10. Not because it's sophisticated. Because it's everywhere, it's trivial to exploit, and developers consistently underestimate how bad it gets.
What Actually Happens in a BOLA Attack
Your API has an endpoint. That endpoint takes an ID. The server fetches the object and returns it. Sounds normal because it is normal — that's how REST works. The problem is the step that's missing: nobody checked whether the authenticated user should be able to see that object.
// This is the entire vulnerability
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
// cool, we checked auth. but does this invoice belong to req.user?
// nope. ship it.
res.json(invoice);
});
Authentication passed. Authorization didn't happen. Two completely different things that teams conflate constantly.
Why Frameworks Don't Save You
Express, Django, Rails, Spring Boot — none of them enforce object-level authorization out of the box. They give you middleware for authentication. They give you role-based guards. But the question "does user X own object Y" is business logic. Your framework can't answer it because it doesn't know your data model.
Django REST Framework has get_queryset filtering. Rails has scoped queries. Spring has @PreAuthorize. These are tools, not solutions. You still have to use them correctly on every single endpoint. Miss one and you've got a hole.
And teams miss them. A lot. Pentesting reports from major firms consistently show BOLA in 30-40% of API assessments. That number hasn't really moved in five years.
The Patterns That Get Exploited
Sequential integer IDs
The classic. /api/orders/50123 becomes /api/orders/50124. An attacker writes a loop and scrapes everything. Switching to UUIDs slows them down but does not fix the authorization problem — people confuse obscurity with security here.
Nested resources without scope checks
// GET /api/organizations/5/employees/312
// Auth middleware confirms: yes, this is a logged-in user
// But... does this user belong to organization 5?
// Does employee 312 belong to organization 5?
// Two checks. Both skipped.
Nested routes create a false sense of hierarchy. Developers assume the nesting implies scoping. It doesn't. Each level needs explicit verification.
GraphQL makes it worse
REST at least has discrete endpoints you can audit one by one. GraphQL lets clients craft arbitrary queries. A single resolver that skips ownership checks exposes data through any query path that touches it. And there are a lot of query paths in a mature schema.
# An attacker doesn't need to guess URLs
# They just query the schema and follow relationships
query {
user(id: "someone-else") {
email
ssn
bankAccounts {
routingNumber
balance
}
}
}
Batch endpoints and bulk operations
Single-object endpoints might have checks. Bulk endpoints often don't. POST /api/export with a body containing {"ids": [1, 2, 3, 4, 5000]} — does the handler verify ownership of every single ID in that array? Usually not.
Real Damage, Real Numbers
Uber's 2016 data breach? BOLA. An attacker accessed driver records by manipulating user IDs in API calls. 57 million records.
Peloton's API leak in 2021 exposed user profiles — age, gender, city, workout stats — because their API returned data for any user ID without verifying the requester had permission. The endpoints were authenticated. Just not authorized.
These aren't obscure startups. These are companies with massive security teams and nine-figure budgets. BOLA slips through because it looks like normal, working code. No static analyzer flags findById(req.params.id) as dangerous. It's a logic flaw, not a syntax problem.
Building Actual Defenses
Forget silver bullets. This requires discipline across the entire codebase.
Scope queries to the authenticated user
// Bad: fetch then check (or forget to check)
const order = await Order.findById(req.params.id);
// Good: query is inherently scoped
const order = await Order.findOne({
_id: req.params.id,
userId: req.user.id // can't return someone else's order
});
This pattern is simple but it changes the failure mode. A bad ID returns 404 instead of someone else's data. The query itself is the authorization check.
Middleware that enforces ownership
// Reusable ownership check
function ownershipGuard(model, ownerField = 'userId') {
return async (req, res, next) => {
const resource = await model.findById(req.params.id);
if (!resource) return res.status(404).json({ error: 'Not found' });
if (resource[ownerField].toString() !== req.user.id) {
// log this — it might be an attack
logger.warn('BOLA attempt', {
user: req.user.id,
resource: req.params.id,
model: model.modelName
});
return res.status(404).json({ error: 'Not found' });
}
req.resource = resource;
next();
};
}
// 404, not 403 — don't confirm the resource exists
app.get('/api/invoices/:id', authenticate, ownershipGuard(Invoice), handler);
Return 404, not 403. A 403 tells an attacker the object exists but they can't access it. That's information leakage on top of access control.
Integration tests that cross user boundaries
Unit tests check happy paths. You need tests that explicitly try to access User A's resources while authenticated as User B. Every endpoint. Every method.
describe('Invoice API - authorization', () => {
it('returns 404 when accessing another user invoice', async () => {
const res = await request(app)
.get(`/api/invoices/${userBInvoice.id}`)
.set('Authorization', `Bearer ${userAToken}`);
expect(res.status).toBe(404);
// also verify the response body contains no invoice data
expect(res.body).not.toHaveProperty('amount');
});
});
If your test suite doesn't have cross-user tests, your test suite has a gap the size of your entire database.
API gateway rate limiting on enumeration patterns
Someone hitting /api/users/1, /api/users/2, /api/users/3 in sequence is not normal traffic. Rate limiting won't fix BOLA but it limits the blast radius and buys time for detection. Tools like Kong or AWS API Gateway can flag sequential ID patterns.
Automated Scanning Catches What Code Review Misses
Manual code review is essential but humans get tired. Reviewer fatigue is real — by the 40th endpoint, the ownership check pattern blurs together and gaps slip through. ScanMyCode.dev runs automated security audits that flag endpoints missing authorization checks, with exact file and line numbers so you know what to fix and where. Catches the stuff that gets waved through at 4 PM on a Friday.
Stop Trusting the Client
BOLA persists because it feels like a non-issue during development. You're testing with your own data, your own user, your own IDs. Everything works. The vulnerability only manifests when a different user tries those same IDs — and your test suite probably doesn't simulate that scenario.
Switching to UUIDs is fine but it's not a fix. Adding authentication is necessary but insufficient. The only real defense is checking ownership on every single data access, treating it as seriously as you treat SQL injection prevention, and testing it as aggressively.
Your API might be leaking data right now and passing all its tests. That's the uncomfortable part. A security audit from ScanMyCode.dev maps every endpoint, checks authorization logic, and delivers a full report within 24 hours. Cheaper than finding out from a breach notification.