Una vulnerabilidad IDOR (insecure direct object reference) aparece cuando tu aplicación toma un identificador del request, carga el registro correspondiente y lo devuelve sin verificar que el usuario actual tenga permiso para verlo. El atacante no necesita ningún exploit sofisticado. Se loguea con una cuenta normal, abre /api/invoices/1001, lo cambia por /api/invoices/1002 y lee la factura de otro cliente.

Es uno de los bugs más fáciles de explicar y uno de los que más se despachan a producción. En esta guía vemos qué es un IDOR, cómo se relaciona con BOLA, cómo se ve el código vulnerable y el corregido en cinco frameworks, dónde se esconde (GraphQL, endpoints masivos, SaaS multi-tenant), cómo testearlo y cómo evitar que vuelva.

Qué es IDOR

Una "referencia directa a un objeto" es cualquier valor que manda el cliente y que apunta a algo interno: un ID de la base, un nombre de archivo, un número de cuenta, un UUID. La referencia en sí no es el problema. El problema es la pregunta que falta en el servidor: ¿este usuario puede acceder a este objeto en particular?

El IDOR es una falla de autorización, no de autenticación. El atacante normalmente está logueado y es un usuario legítimo. La autenticación responde "¿quién sos?"; la autorización responde "¿qué podés tocar?". El IDOR vive en el hueco entre las dos.

Tiene dos variantes:

  • Horizontal: un usuario accede a datos de otro usuario con el mismo rol (pedidos, mensajes o documentos de otro cliente).
  • Vertical: un usuario llega a un objeto o una acción de un rol superior, por ejemplo un reporte de admin que se pide por ID.

IDOR vs BOLA: el mismo bug con dos nombres

En el OWASP Top 10 2025, el IDOR está dentro de A01:2025 Broken Access Control, que sigue en el puesto número uno. OWASP pone como ejemplo típico permitir ver o editar la cuenta de otra persona con solo proveer su identificador único ("insecure direct object references") y lo mapea a CWE-639, Authorization Bypass Through User-Controlled Key. Si querés el panorama completo de la categoría, mirá nuestra guía de broken access control.

En el OWASP API Security Top 10 (edición 2023), la misma falla se llama BOLA, Broken Object Level Authorization, y es API1:2023, el primer ítem. OWASP la describe como atacantes que manipulan el ID de un objeto enviado en el request. Así que "bola vulnerability" y "broken object level authorization" son, en la práctica, IDOR en una API.

Ejemplos del mundo real

IDs secuenciales

El caso de manual: GET /api/orders/48213. Los IDs enteros son predecibles, así que un script recorre del 1 al 50.000 y se baja todos los pedidos del sistema en minutos. Lo mismo pasa con descargas de archivos como /receipts/48213.pdf servidas desde un storage sin chequeo.

IDs en el body, no en la URL

Muchos IDOR se esconden en el JSON: {"user_id": 812, "email": "nuevo@ejemplo.com"} enviado a un endpoint de actualización de perfil. El handler confía en el user_id del body en vez de sacarlo de la sesión, y cualquiera puede cambiarle el mail a cualquiera y después resetearle la contraseña.

Los UUIDs no son autorización

Un falso arreglo muy común es pasarse a UUIDs. OWASP recomienda IDs aleatorios e impredecibles, pero como defensa en profundidad, no como el control. Los UUIDs se filtran por todos lados: links compartidos, historial del navegador, logs, analytics, payloads de webhooks, exports a CSV y otras respuestas de la API (un listado que devuelve objetos de tus compañeros de equipo, por ejemplo). Apenas el atacante tiene uno, un endpoint sin chequeo se lo sirve sin problema. Difícil de adivinar no es lo mismo que autorizado.

Código vulnerable y corregido en 5 frameworks

El patrón es igual en todos lados: dejá de cargar objetos de forma global y empezá a cargarlos a través del usuario o el tenant actual. Si el objeto no está en su alcance, para ese usuario no existe, y devolvés 404.

Rails

# Vulnerable: cualquier usuario logueado lee cualquier factura
def show
  @invoice = Invoice.find(params[:id])
  render json: @invoice
end

# Corregido: se busca a través de la asociación (RecordNotFound, o sea 404)
def show
  @invoice = current_user.invoices.find(params[:id])
  render json: @invoice
end

Para algo más que una asociación, centralizá la regla en una policy. Con Pundit:

class InvoicePolicy < ApplicationPolicy
  def show? = record.account_id == user.account_id

  class Scope < ApplicationPolicy::Scope
    def resolve = scope.where(account_id: user.account_id)
  end
end

# controller
def show
  @invoice = policy_scope(Invoice).find(params[:id])
  authorize @invoice
end

Con after_action :verify_authorized, Pundit hace que un chequeo olvidado explote en vez de pasar en silencio. Con CanCanCan, el equivalente es can :read, Invoice, account_id: user.account_id en la ability y load_and_authorize_resource en el controller.

Express / Node.js

// Vulnerable
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await Invoice.findByPk(req.params.id);
  res.json(invoice);
});

// Corregido: la condición de dueño es parte de la query
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await Invoice.findOne({
    where: { id: req.params.id, accountId: req.user.accountId },
  });
  if (!invoice) return res.status(404).json({ error: 'Not found' });
  res.json(invoice);
});

Django

# Vulnerable
def invoice_detail(request, pk):
    invoice = get_object_or_404(Invoice, pk=pk)
    return JsonResponse(invoice.as_dict())

# Corregido
def invoice_detail(request, pk):
    invoice = get_object_or_404(Invoice, pk=pk, account=request.user.account)
    return JsonResponse(invoice.as_dict())

# Django REST Framework: filtrá el queryset, no cada vista
class InvoiceViewSet(viewsets.ModelViewSet):
    serializer_class = InvoiceSerializer
    def get_queryset(self):
        return Invoice.objects.filter(account=self.request.user.account)

Laravel

// Vulnerable: el route model binding carga cualquier factura
public function show(Invoice $invoice)
{
    return $invoice;
}

// Corregido: decide una policy, el controller pregunta
public function show(Invoice $invoice)
{
    Gate::authorize('view', $invoice);
    return $invoice;
}

// app/Policies/InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
    return $user->account_id === $invoice->account_id;
}

Spring Boot

// Vulnerable
@GetMapping("/invoices/{id}")
public Invoice get(@PathVariable Long id) {
    return repo.findById(id).orElseThrow();
}

// Corregido: un método del repositorio que exige el dueño
@GetMapping("/invoices/{id}")
public Invoice get(@PathVariable Long id, @AuthenticationPrincipal AppUser user) {
    return repo.findByIdAndAccountId(id, user.getAccountId())
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

Dónde se esconde: GraphQL y endpoints masivos

GraphQL. Un solo schema expone objetos por muchos caminos: invoice(id:), el campo genérico node(id:), campos anidados como customer { invoices { ... } } y mutations como updateInvoice(id:). Es común proteger la query de primer nivel y olvidarse del resolver anidado o de la mutation. Poné la autorización en la capa de datos que usan todos los resolvers (un loader con scope o una policy), no en cada resolver por separado.

Endpoints masivos y de export.POST /api/invoices/export con {"ids": [1, 2, 3]} muchas veces corre Invoice.where(id: ids). Un chequeo solo sobre el primer ID, o ninguno, y el atacante exporta miles de registros ajenos en una llamada. Filtrá el conjunto entero: current_account.invoices.where(id: ids).

Escrituras, no solo lecturas. Los handlers de update y delete están igual de expuestos, y es peor: un request que setea account_id en el body puede mover un objeto a otro tenant. Nunca aceptes campos de ownership desde el cliente.

Trampas típicas en SaaS multi-tenant

  • Chequear el usuario pero no el tenant. El usuario es de la cuenta A, el proyecto es de la cuenta B, y el código solo verifica que esté logueado y que sea admin de algún proyecto.
  • Confiar en un header de tenant. Un X-Account-Id que manda el frontend es input del cliente. Sacá el tenant de la sesión o del token y verificá la membresía.
  • Objetos hijos./projects/7/tasks/99 verifica que el proyecto 7 sea tuyo y después carga la tarea 99 de forma global. Cargá el hijo a través del padre: project.tasks.find(99).
  • Jobs en background y webhooks. Un job encolado con un ID crudo, o una URL firmada que nunca vence, se saltea por completo los chequeos del controller.
  • Herramientas internas de admin y soporte. Endpoints internos reutilizados por la app de clientes son una fuente clásica de IDOR vertical.

El row-level security en la base (por ejemplo, policies RLS de PostgreSQL atadas a una variable de tenant) es una muy buena segunda capa: aunque te olvides un scope en la aplicación, no se puede cruzar de tenant.

Cómo testear un IDOR

  1. Creá dos cuentas (idealmente en dos tenants distintos) y algunos objetos en cada una.
  2. Pasá el tráfico por Burp Suite mientras usás la cuenta A. Cada request que lleva un ID en el path, el query string, el body o un header es candidato.
  3. Repetí esos requests con la sesión de la cuenta B y los IDs de la cuenta A. Para pocos casos alcanza con Burp Repeater; extensiones como Autorize automatizan el cambio de sesión en todos los requests.
  4. Probá todos los verbos: GET, PUT, PATCH, DELETE, más exports, adjuntos y mutations de GraphQL.
  5. Compará respuestas, no solo status codes. Un 200 con body vacío puede esconder una escritura exitosa; un 403 distinto de un 404 le confirma al atacante que el objeto existe.

Esto es trabajo estándar en cualquier pentesting y en el pentesting de APIs asistido por IA.

Por qué el SAST basado en patrones no ve el IDOR

El SAST clásico sigue el input no confiable hasta un sink peligroso. Eso funciona para la inyección SQL, donde req.query.sort termina concatenado en un string de query. Un IDOR no tiene sink peligroso: Invoice.find(params[:id]) es una query parametrizada y perfectamente segura. Lo que está mal es una condición que falta. Ninguna regex matchea una línea que no existe.

El análisis que razona lo encara como lo haría un revisor. Nota que InvoicesController#show filtra por current_user y InvoicesController#download no, que existe una policy pero una acción nunca llama a authorize, o que una mutation de GraphQL se saltea el loader que usan todas las queries. Esa comparación entre archivos y convenciones es donde un modelo de lenguaje aporta, y es la razón por la que la revisión de código segura siempre encontró IDORs que los scanners no veían.

Nurbak va por ese camino: conectás GitHub y escaneás un repositorio, y el propio modelo de IA self-hosted de Nurbak analiza el código (el análisis no manda tu código a OpenAI ni a Anthropic). Reporta vulnerabilidades explotables, incluyendo IDOR/BOLA y chequeos de autorización faltantes, con archivo, línea y una explicación en lenguaje claro, además de CVEs en dependencias y secretos en el historial de git, todo resumido en un score de 0 a 100. Mirá cómo funciona en la página del escáner de seguridad de APIs o leé más sobre cómo encontrar vulnerabilidades en el código.

Escribí tests de regresión de autorización

La propia guía de OWASP sobre BOLA dice que escribas tests para el mecanismo de autorización y que no despliegues cambios que los hagan fallar. Un IDOR arreglado sin test suele volver en el próximo refactor. El test es corto: dos usuarios, un objeto, y verificás que el extraño no obtiene nada.

# spec/requests/invoices_spec.rb
RSpec.describe "Invoices authorization", type: :request do
  let(:owner)    { create(:user) }
  let(:stranger) { create(:user) } # otra cuenta
  let!(:invoice) { create(:invoice, account: owner.account) }

  it "hides another account's invoice" do
    sign_in stranger
    get "/api/invoices/#{invoice.id}"
    expect(response).to have_http_status(:not_found)
  end

  it "does not let a stranger delete it" do
    sign_in stranger
    delete "/api/invoices/#{invoice.id}"
    expect(Invoice.exists?(invoice.id)).to be(true)
  end
end

La misma forma sirve con supertest en Node, pytest con el test client de Django, Pest o PHPUnit en Laravel y MockMvc en Spring. Hacelo costumbre: todo endpoint nuevo que recibe un ID sale con su test del "extraño". Cuando Nurbak encuentra un problema así, puede abrir un Pull Request con el fix y un test de regresión de seguridad de este tipo (el fix se genera con Claude, solo con tu consentimiento explícito).

Checklist para prevenir IDOR

  • Cargá los objetos a través del usuario o el tenant actual, nunca de forma global.
  • Sacá la identidad y el tenant de la sesión o del token, nunca del body ni de headers.
  • Centralizá las reglas en policies y hacé que un chequeo faltante falle (denegar por defecto).
  • Devolvé 404 para objetos fuera del alcance del usuario.
  • Cubrí recursos anidados, endpoints masivos, exports, descargas de archivos, resolvers y mutations de GraphQL.
  • Usá IDs aleatorios como defensa en profundidad, no como el control.
  • Sumá un test de regresión con dos cuentas para cada endpoint que recibe un ID.

En resumen

El IDOR no tiene nada de exótico. Es un where que falta, repetido en decenas de endpoints, y le da al atacante justo lo que busca: datos de otras personas, sin disparar ni un error. Filtrá cada query, centralizá tus policies, testeá con dos cuentas y tratá los endpoints escritos por IA con la misma desconfianza que los escritos por personas (nuestra guía de revisión de seguridad con Claude Code cubre ese flujo). El escaneo gratis de Nurbak te muestra completos los 3 hallazgos más importantes, y los planes arrancan en USD 79 por mes si querés cobertura continua. Empezá con el escáner de seguridad de APIs sobre el repo que sirve tu API.

Para seguir leyendo