0x55aa
Back to Blog

🔓 BOLA: The Vulnerability Hiding Behind Your Own User ID

|
5 min read

🔓 BOLA: The Vulnerability Hiding Behind Your Own User ID

Here's a party trick that has ended more security reviews than any zero-day: open your own account, find a URL with a number in it, and change the number.

GET /api/invoices/8841

Change it to 8840. If that returns someone else's invoice instead of a 403, congratulations — you just found a Broken Object Level Authorization bug, and you found it in about four seconds without writing a single line of exploit code. BOLA (you might know it by its older, folksier name, IDOR — Insecure Direct Object Reference) has sat at or near #1 on the OWASP API Security Top 10 for multiple years running, and it's not because it's clever. It's because it's boring — which is exactly why it slips past code review.

Why This Keeps Happening

The uncomfortable truth about BOLA is that the code looks completely fine. It compiles, it passes the happy-path test, the demo works. Nobody writes:

// let anyone read anyone's invoice, who cares
app.get('/api/invoices/:id', (req, res) => {
  const invoice = db.invoices.findById(req.params.id);
  res.json(invoice);
});

on purpose. Nobody thinks that's what they wrote either. What actually happens is that authentication and authorization get quietly conflated in the developer's head: "the user is logged in, the endpoint requires a valid token, therefore the endpoint is secure." Authentication answers "who are you." Authorization has to separately answer "are you allowed to touch this specific object" — and that second question is the one that gets skipped, because it requires an extra database lookup that doesn't feel like it "does" anything from a feature-completeness standpoint. It's pure risk-reduction plumbing, the kind of code that only ever proves its worth by not showing up in an incident report.

The fix is almost insultingly small:

app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);

  if (!invoice || invoice.ownerId !== req.user.id) {
    return res.status(404).end(); // 404, not 403 — don't confirm it exists
  }

  res.json(invoice);
});

One if statement. That's the entire vulnerability class, in both directions. And that gap between "trivial to fix" and "constantly shipped anyway" is exactly why BOLA refuses to die — it's not a hard problem, it's an easy-to-forget one, and those are the worst kind because there's no error message nudging you toward the fix. The request succeeds. It just succeeds for the wrong person.

It's Not Just URLs Anymore

The old mental model — "watch out for sequential IDs in the path" — undersells how this shows up in a modern API surface. GraphQL is arguably worse territory for BOLA because a single query can request a dozen nested object types in one round trip, and every single nested resolver needs its own ownership check or the whole "just switch to UUIDs" advice becomes theater:

query {
  user(id: "self") {
    organization {
      invoices(first: 50) { id, amount, customerName }
      billingAddress { street, city }
    }
  }
}

If the invoices resolver trusts the parent organization context without re-verifying the caller actually belongs to that org — say, because someone forged or guessed an org ID upstream — you've got the same bug, just three levels deep and much harder to spot in a diff, because the vulnerable line isn't the route handler anymore, it's a resolver buried in a schema file nobody reads end to end.

And switching from sequential integers to UUIDs, the most common "fix" I see proposed, doesn't actually fix BOLA — it just makes the enumeration attack slower. The authorization check is still missing; you've just made brute-forcing IDs impractical while leaving the door unlocked for anyone who already has one valid ID from a legitimately shared link, a leaked log line, or a referrer header.

What Actually Catches This

At Cubet, the thing that moved the needle wasn't a linting rule or a training deck — it was making "does this resolver/handler verify ownership" a mandatory checklist item in PR review for anything touching a new resource type, the same way "did you add an index" is a reflex for new queries. Automated scanners genuinely struggle here because BOLA is a logic bug, not a pattern bug — the request is syntactically perfect, properly authenticated, and semantically wrong only because of who's asking. A WAF has no idea that user 41 shouldn't see invoice 8840; both look like completely normal, well-formed traffic.

The closest thing to a systematic catch is contract testing that runs every read/write endpoint twice per test case: once as the resource's actual owner, once as a second, unrelated authenticated user who should get a 403 or 404. If your test suite only ever authenticates as "the" test user, you have zero coverage for this entire vulnerability class no matter how green your CI is — every test proves the happy path works and says nothing about the wrong-person path, which is the only path that actually matters here.

The One-Sentence Rule

Every object-returning endpoint needs to answer two questions before it returns anything: is this user real, and does this user own or have explicit access to this exact object. Skip the first and you've got no authentication. Skip the second and you've got a working, fully-authenticated, perfectly logged API that happily hands strangers each other's data — and cheerfully returns a 200 OK while it does it.


Found a BOLA in the wild, or want to argue that UUIDs are underrated as a mitigation anyway? Come yell at me:

Share:LinkedInXHacker News

Thanks for reading!

Back to all posts