BOLA / IDOR: Object Level Authorization

The single most common API vulnerability — change an id, get someone else's data — and the pattern that eliminates it.

Broken Object Level Authorization (OWASP API1), historically called IDOR, is the number one API vulnerability in the wild. The endpoint authenticates perfectly. It then returns object 1044 to the person who owns 1043.

Why it is everywhere

  • It is invisible to scanners. Only your application knows that invoice 1044 belongs to someone else — a tool sees two successful requests.
  • It requires no skill. Change a number in a URL.
  • It is easy to write. findById(req.params.id) is the obvious code, and it is wrong.
  • It scales. A loop over a million ids downloads your entire customer base overnight.

The fix, stated once

The authorization check must be part of the query, not a step after it.

// ❌ fetch, then check — invites the forgotten check
const invoice = await db.invoices.findById(id);
if (invoice.userId !== req.user.id) return res.sendStatus(403);

// ✅ scope the query — a foreign object simply is not found
const invoice = await db.invoices.findOne({ id, userId: req.user.id });
if (!invoice) return res.sendStatus(404);

404, not 403

Returning 403 confirms the object exists. Over many requests that is an enumeration oracle: an attacker learns which ids are real without reading any of them. Return 404 for "not yours" and "not there" alike.

UUIDs are not the fix

Unguessable ids raise the effort and are worth using, but they are obfuscation. Ids leak — in shared links, emails, exports, referrers, support tickets, and other API responses. The authorization check is the control; the UUID is a speed bump.

Where it hides

The GET /resource/:id route is usually reviewed. The bugs live on the second path to the same data: nested routes, bulk endpoints that accept an array of ids, exports, search indexes, GraphQL resolvers, webhook payloads, and the admin panel that bypasses the service layer.

Example

Example · bash
# The whole attack. No tools required.
for id in $(seq 1000 2000); do
  curl -s -o "invoice-$id.json" -w "$id %{http_code}\n" \
    "https://dfg.com/api/invoices/$id" -H "Authorization: Bearer $MY_TOKEN"
done

# 1000 200   ← mine
# 1001 200   ← not mine
# 1002 200   ← not mine
# ...

# And the version that leaks even when the check exists but returns 403:
# 1043 403   ← exists, not mine
# 1044 404   ← does not exist
# The status code alone maps your entire id space.

When to use it

  • A ride-hailing app exposes every trip's pickup and drop-off because the trip endpoint fetched by id without scoping to the rider.
  • A bulk endpoint accepting an array of ids returns a mix of the caller's and other users' records, because only the first id was checked.
  • A GraphQL resolver returns objects the REST route correctly protects, since the ownership check lived in the controller rather than the data layer.

More examples

Every shape the bug takes

Shape 5 is the one that survives reviews: the object being created is legitimately the caller's, so nobody thinks to check the foreign key it points at.

Example · javascript
// 1. THE CLASSIC
app.get('/api/invoices/:id', auth, async (req, res) => {
  res.json(await db.invoices.findById(req.params.id));            // ❌
});
app.get('/api/invoices/:id', auth, async (req, res) => {
  const invoice = await db.invoices.findOne({
    id: req.params.id, userId: req.user.id,                       // ✅
  });
  if (!invoice) return res.status(404).json({ error: 'not_found' });
  res.json(InvoiceSerializer.public(invoice));
});

// 2. THE NESTED ROUTE — the parent is checked, the child is not
app.get('/api/projects/:pid/tasks/:tid', auth, async (req, res) => {
  const project = await db.projects.findOne({ id: req.params.pid, ownerId: req.user.id });
  if (!project) return res.sendStatus(404);
  res.json(await db.tasks.findById(req.params.tid));              // ❌ any task
});
// ✅ the child must belong to the checked parent
const task = await db.tasks.findOne({ id: req.params.tid, projectId: project.id });

// 3. THE BULK ENDPOINT — one check, many objects
app.post('/api/invoices/bulk', auth, async (req, res) => {
  res.json(await db.invoices.findMany(req.body.ids));             // ❌
});
// ✅ scope the whole set, and refuse partial matches loudly
const invoices = await db.invoices.findMany({
  id: { in: req.body.ids }, userId: req.user.id,
});
if (invoices.length !== req.body.ids.length) {
  return res.status(404).json({ error: 'not_found' });   // no partial disclosure
}

// 4. THE WRITE — reading is checked, writing is not
app.patch('/api/invoices/:id', auth, async (req, res) => {
  await db.invoices.update(req.params.id, req.body);              // ❌
});
// ✅ the WHERE clause carries the authorization
const updated = await db.invoices.updateWhere(
  { id: req.params.id, userId: req.user.id }, patch);
if (!updated) return res.sendStatus(404);

// 5. THE REFERENCE IN A BODY — the object is yours, the reference is not
app.post('/api/invoices', auth, async (req, res) => {
  // customerId comes from the client. Is that customer theirs?
  const customer = await db.customers.findOne({
    id: req.body.customerId, userId: req.user.id,                 // ✅
  });
  if (!customer) return res.status(400).json({ error: 'unknown_customer' });
  res.status(201).json(await db.invoices.create({ ..., customerId: customer.id }));
});

// 6. THE FILE — the id is checked, the storage key is not
app.get('/api/invoices/:id/pdf', auth, async (req, res) => {
  const invoice = await db.invoices.findOne({ id: req.params.id, userId: req.user.id });
  if (!invoice) return res.sendStatus(404);
  // ✅ derive the key from the ROW, never from the request
  res.redirect(await signedUrl(invoice.pdfKey, { expiresIn: 300 }));
});

Enforcing it where it cannot be forgotten

Option B's explicit opt-out is the useful property: withoutGlobalScope is greppable, so every intentional bypass is a reviewable line.

Example · javascript
// Reviewing every query forever does not scale. Push the check down a layer.

// ── Option A: a scoped repository, constructed per request ────────────
class ScopedRepo {
  constructor(model, ownerColumn, ownerId) {
    if (!ownerId) throw new Error('ScopedRepo requires an owner');
    Object.assign(this, { model, ownerColumn, ownerId });
  }
  #scope(where = {}) { return { ...where, [this.ownerColumn]: this.ownerId }; }

  find(id)          { return db(this.model).where(this.#scope({ id })).first(); }
  list(where)       { return db(this.model).where(this.#scope(where)); }
  update(id, patch) { return db(this.model).where(this.#scope({ id })).update(patch); }
  delete(id)        { return db(this.model).where(this.#scope({ id })).del(); }
  // No unscoped method exists.
}

app.use(auth, (req, res, next) => {
  req.repos = {
    invoices: new ScopedRepo('invoices', 'user_id', req.user.id),
    projects: new ScopedRepo('projects', 'owner_id', req.user.id),
  };
  next();
});

app.get('/api/invoices/:id', async (req, res) => {
  const invoice = await req.repos.invoices.find(req.params.id);   // safe by default
  if (!invoice) return res.sendStatus(404);
  res.json(InvoiceSerializer.public(invoice));
});

// ── Option B: an ORM global scope (Laravel) ───────────────────────────
// class Invoice extends Model {
//     protected static function booted(): void {
//         static::addGlobalScope('owner', function (Builder $q) {
//             if (auth()->check()) $q->where('user_id', auth()->id());
//         });
//     }
// }
// Invoice::find($id) is now scoped everywhere, including in code not yet written.
// Admin paths opt out EXPLICITLY: Invoice::withoutGlobalScope('owner')

// ── Option C: the database enforces it (see the RLS lesson) ───────────
// Even a raw query written at 2am cannot cross the boundary.

A test that finds it across the whole API

Asserting the row is unchanged — not just that the status was 404 — catches handlers that perform the mutation before evaluating ownership.

Example · javascript
// Generate the test matrix from the route inventory so new routes are covered
// automatically rather than when someone remembers.
import routes from '../route-inventory.json' with { type: 'json' };

const objectRoutes = routes.filter((r) => r.takesId && !r.adminOnly);

describe('BOLA: no route returns another user\'s object', () => {
  let alice, bob, aliceToken, bobToken, fixtures;

  beforeAll(async () => {
    ({ alice, bob, aliceToken, bobToken } = await seedTwoUsers());
    // One object of every type, owned by BOB.
    fixtures = await seedObjectsFor(bob);
    // { invoices: 'uuid-1', projects: 'uuid-2', tasks: 'uuid-3', ... }
  });

  for (const route of objectRoutes) {
    const resource = route.path.split('/')[2];              // /api/invoices/:id
    const id = () => fixtures[resource];
    if (!id) continue;

    it(`${route.method} ${route.path} does not expose Bob's ${resource}`, async () => {
      const res = await request(app)
        [route.method.toLowerCase()](route.path.replace(/:\w+/, id()))
        .set('Authorization', `Bearer ${aliceToken}`)
        .send(route.method === 'GET' ? undefined : { name: 'modified' });

      // 404 — never 200, and never 403 (which confirms existence)
      expect(res.status).toBe(404);
    });

    if (route.method !== 'GET') {
      it(`${route.method} ${route.path} does not MODIFY Bob's ${resource}`, async () => {
        const before = await db(resource).where({ id: id() }).first();
        await request(app)[route.method.toLowerCase()](route.path.replace(/:\w+/, id()))
          .set('Authorization', `Bearer ${aliceToken}`)
          .send({ name: 'modified' });
        const after = await db(resource).where({ id: id() }).first();
        expect(after).toEqual(before);           // a 404 that still wrote is worse
      });
    }
  }
});

// The second assertion catches the subtle version: the handler returns 404 but
// the write already happened before the check.

Discussion

  • Be the first to comment on this lesson.