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.
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.