Broken object-level authorization (IDOR)¶
When is it relevant¶
A new or changed entry point that takes a resource identifier (path parameter, query, body, or a nested or bulk list of IDs) and reads, changes or deletes that resource or its children.
Attack¶
The attacker replaces the ID with one that belongs to someone else, and the server acts on it because it only checks that the caller is logged in. IDs hidden in the body, child resources checked only through their parent, and "unguessable" UUIDs that leak through other responses are all easy to miss.
Mitigation¶
- Load the resource scoped to the caller (
WHERE id = ? AND owner_id = ?), or check ownership after loading and before acting. - Check every ID in the request, including IDs in the body and IDs of child resources.
- Return the same
404for "doesn't exist" and "not yours". - Don't rely on IDs being hard to guess.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
Scope the query (WHERE owner_id = ?) |
Default choice; the check can't be forgotten later | node-postgres, Mongoose queries |
| Load then check in the handler | You need the record anyway, or the rule is more than ownership | Express routing |
| Check in the data-access layer | Many handlers share one repository function | CASL |
Vulnerable — authenticated, but not authorised
router.delete('/documents/:id', requireAuth, async (req, res) => {
await db.query('DELETE FROM documents WHERE id = $1', [req.params.id])
res.sendStatus(204)
})
Vulnerable — child object checked through its parent only
router.get('/projects/:projectId/tasks/:taskId', requireAuth, async (req, res) => {
const project = await Project.findOne({ _id: req.params.projectId, ownerId: req.user.id })
if (!project) return res.sendStatus(404)
res.json(await Task.findById(req.params.taskId)) // taskId never tied to project
})
Mitigated — scope the query
router.delete('/documents/:id', requireAuth, async (req, res) => {
const { rowCount } = await db.query(
'DELETE FROM documents WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.id],
)
if (rowCount === 0) return res.sendStatus(404)
res.sendStatus(204)
})
The same 404 covers "doesn't exist" and "not yours", so the response doesn't
confirm that someone else's ID is real.
Docs: node-postgres parameterised queries
Mitigated — load then check
const doc = await Document.findById(req.params.id)
if (!doc) return res.sendStatus(404)
if (String(doc.ownerId) !== String(req.user.id)) return res.sendStatus(404)
await doc.deleteOne()
Docs: Mongoose queries
Mitigated — data-access layer
// repositories/documents.js — handlers cannot reach the model directly
export function forUser(user) {
return {
find: (id) => Document.findOne({ _id: id, ownerId: user.id }),
remove: (id) => Document.deleteOne({ _id: id, ownerId: user.id }),
}
}
Bypasses to check
- Check one ID, act on another. Ownership verified for
req.params.idwhile the write usesreq.body.id, or a bulk route (ids: []) that checks only the first element. - Nested and related IDs: child resources,
populate()/JOINtargets, attachments, and "duplicate"/"export"/"share" routes reached from an object you do own. - Ownership compared with
==across types (ObjectIdvs string), ordoc.ownerId === req.user.idwhere one side is an object — always false or always true rather than a real comparison. - The owner column taken from the request (
req.body.ownerId) instead of the session. - Read path scoped but the write path not, or vice versa.
- Check then change: the record is re-fetched without the scope after the
check, or the ID is re-parsed (
parseInt,trim) between check and use. findByIdAndUpdate(req.params.id, ...)with the ownership filter omitted because "the UI only shows your own documents".
What to grep for
findById(,findByIdAndUpdate(,findByIdAndDelete(,deleteOne(req.params.id,req.body.id,req.query.idWHERE id =,{ _id:without an owner or tenant field alongside
Vulnerable
@login_required
def delete_document(request, pk):
Document.objects.filter(pk=pk).delete()
return HttpResponse(status=204)
Mitigated
@login_required
def delete_document(request, pk):
document = get_object_or_404(Document, pk=pk, owner=request.user)
document.delete()
return HttpResponse(status=204)
get_object_or_404 raises the same 404 whether the document belongs to
someone else or doesn't exist.
Vulnerable
Mitigated
Scoping through the association means find raises RecordNotFound, which
Rails renders as 404, for documents owned by anyone else.