Skip to content
Milian Lastre ZequeiraEN
security2 min

IDOR and broken object-level authorization: repro and mitigation

What an IDOR is, how to reproduce it by changing identifiers, and how to fix it at the root.

— MLZsecuritywebpentest

I build APIs, and I also break them to prove they hold. The vulnerability I find most often in reviews is the most boring to name and the most expensive to ignore: broken object-level authorization (BOLA), classically known as IDOR. It's number 1 on the OWASP API Security Top 10. This writeup is generic and lab-only — no real targets.

What it is

An IDOR happens when an endpoint identifies an object by a client-supplied ID and checks authentication but not authorization over that object. You're logged in, sure — but nobody checks that the resource is yours.

Lab scenario

An API exposes invoices by incremental ID:

GET /api/invoices/1001
Authorization: Bearer <user-A-token>

It returns invoice 1001. User A changes the ID to 1002 and receives another account's invoice. The session is valid; the authorization is nonexistent.

Reproduction

The pattern is to enumerate the identifier. In an authorized lab:

# Authorized / CTF environments only. Enumerate a numeric ID while authenticated.
ffuf -w ids.txt \
     -u https://lab.example/api/invoices/FUZZ \
     -H "Authorization: Bearer $TOKEN" \
     -mc 200 -fr '"error"'

Every 200 carrying someone else's invoice is a finding. Sequential IDs make it trivial; and using UUIDs does not fix it — it only raises the cost of enumeration. A UUID leaked in a URL, a log, or a Referer header is still directly accessible.

Root cause

The server trusts the client's ID as if it were proof of ownership. The query says "fetch the object with this ID," when it should say "fetch the object with this ID that belongs to the caller."

Root-cause mitigation

Scope the query to the owner and deny by default:

// Vulnerable: authenticated, but no object-level authorization
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoice.findById(req.params.id);
  res.json(invoice); // returns any invoice
});
 
// Fixed: the query is scoped to the caller
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: { id: req.params.id, ownerId: req.user.id },
  });
  if (!invoice) return res.sendStatus(404); // 404, not 403 — don't leak existence
  res.json(invoice);
});

Defense checklist:

  • Authorize on the object, don't just authenticate the session.
  • Scope every query by owner/tenant at the data layer.
  • 404, not 403, so you don't reveal which IDs exist.
  • Don't trust client IDs as proof of access; UUIDs mitigate, they don't authorize.
  • Log denied accesses to detect enumeration.

The rule is simple and applies to building as much as to auditing: identity answers who you are; authorization answers what you're entitled to. Never confuse one for the other.

Let's build something of our own

Let's talk