Skip to content

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 404 for "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.id while the write uses req.body.id, or a bulk route (ids: []) that checks only the first element.
  • Nested and related IDs: child resources, populate()/JOIN targets, attachments, and "duplicate"/"export"/"share" routes reached from an object you do own.
  • Ownership compared with == across types (ObjectId vs string), or doc.ownerId === req.user.id where 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.id
  • WHERE 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

def destroy
  Document.find(params[:id]).destroy
  head :no_content
end

Mitigated

def destroy
  current_user.documents.find(params[:id]).destroy
  head :no_content
end

Scoping through the association means find raises RecordNotFound, which Rails renders as 404, for documents owned by anyone else.

Resources