Volver al blog
IDORBroken Access ControlAutorizaciónOWASPNext.jsNode.jsPrismaSeguridad de API

IDOR y Broken Access Control en Next.js y Node.js: autorización a nivel de objeto que aguanta [2026]

Autenticar no es autorizar

Tu middleware se ejecuta, getSession() devuelve un usuario y el route handler continúa. Esa comprobación responde exactamente a una pregunta — ¿hay una sesión válida en esta petición? — y no dice nada sobre si esa sesión tiene permiso para leer el objeto cuyo ID acaba de llegar en la URL. Ese hueco es el control de acceso roto (Broken Access Control): la categoría A01 del OWASP Top 10 desde 2021, por encima de la inyección y de los fallos criptográficos. Su forma concreta más común es el IDOR — Insecure Direct Object Reference (referencia directa insegura a objetos).

El patrón es aburrido, y por eso sobrevive a la revisión de código. Un usuario cambia /api/invoices/inv_1042 por /api/invoices/inv_1043 y lee la factura de otra persona. Sin payload de explotación, sin truco de codificación, sin ruptura criptográfica: solo un identificador que nunca se comprobó contra quien llama. Tres propiedades convierten esta en la clase de bug más cara del top 10:

  1. La mayoría de los escáneres no la ven. La petición está autenticada y es sintácticamente válida. La respuesta es un 200 normal. Nada le parece anómalo a un fuzzer que no tiene dos cuentas.
  2. Es trivialmente automatizable. Recorre el espacio de IDs, compara tamaños de respuesta, extrae. Con enteros autoincrementales es un bucle for. Con un endpoint por lotes tipo /api/users?ids= es una sola petición.
  3. El radio de impacto es una brecha de datos. No es un desfigurado, no es un DoS: son los registros de todos los demás inquilinos, descargables como JSON.

Esta guía trata sobre dónde se rompe realmente la autorización a nivel de objeto en un código de Next.js + Node.js: los patrones de consulta que la causan, los patrones de consulta que la arreglan, por qué el middleware no puede ser el control y los tests de regresión que la mantienen arreglada.

IDOR en una sola petición

Dos cuentas, un endpoint. El atacante es un cliente legítimo, que paga y está plenamente autenticado en tu producto:

bash
# Authenticated as account B. Account A's invoice ID is not a secret —
# it appears in logs, in emails you send, in a shared screenshot, or in a sequence.
curl -s "https://app.example.com/api/invoices/inv_1042" \
  -H "Cookie: session=<account-B-session>"

# 200 OK
# {"id":"inv_1042","tenantId":"acme","number":"INV-2026-0187",
#  "totalCents":4825000,"customerEmail":"cfo@acme.com","notes":"..."}

No se eludió nada. No había autenticación que eludir: la petición estaba perfectamente autenticada, como el principal equivocado. Si los IDs son secuenciales (1042, 1043, 1044), un solo script enumera toda tu tabla de clientes en segundos, y la respuesta le dice al atacante exactamente cuánto vale cada cliente.

La escalada de privilegios horizontal es esto: mismo rol, distinto inquilino o distinto usuario. La escalada vertical es un usuario normal alcanzando objetos solo de admin. Ambas son el mismo predicado ausente en la misma consulta.

Por qué los stacks de JavaScript filtran referencias a objetos

Cuatro razones estructurales por las que este bug es tan frecuente concretamente en Node.js y Next.js:

  • Los route handlers son proxies finos hacia el ORM. findUnique({ where: { id } }) se lee como una consulta segura: el ORM está parametrizado, no hay inyección SQL, así que el código "parece seguro". La parametrización protege contra la inyección; no hace nada respecto a la autorización. La consulta devuelve la fila porque la fila existe, no porque quien llama sea su propietario.
  • El ID ya está en una posición que controla el cliente. Segmentos dinámicos ([id]), query strings (?invoice=…), cuerpos de petición, variables de GraphQL, datos de formulario de Server Actions — todos ellos son input controlado por el atacante que casualmente parece una clave.
  • Las convenciones del framework ocultan la frontera. Los route handlers de Next.js no son controladores con una capa de políticas; no existe un before_action al estilo Rails. La autorización es algo que debes acordarte de escribir, en cada handler, para siempre.
  • Los includes de datos relacionados multiplican el error. Un predicado ausente en un registro padre es un bug; el mismo predicado ausente en un include de una colección anidada es una exportación masiva.

El patrón vulnerable — y su gemelo invisible

ts
// app/api/invoices/[id]/route.ts — VULNERABLE
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await getSession();
  if (!session) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  const { id } = await params;

  // Authentication passed. Ownership was never part of the question.
  const invoice = await prisma.invoice.findUnique({ where: { id } });
  if (!invoice) {
    return NextResponse.json({ error: "not found" }, { status: 404 });
  }

  return NextResponse.json(invoice); // any tenant's invoice, to any session
}

Ahora la ruta de escritura, que es peor porque es destructiva y a menudo se pasa por alto:

ts
// VULNERABLE — a cross-tenant write. The read bug and the write bug are the same bug.
await prisma.invoice.delete({ where: { id } });          // deletes anyone's invoice
await prisma.invoice.update({ where: { id }, data: { status: "PAID" } });

Y la tercera variante, que los revisores aprueban porque parece una comprobación:

ts
// VULNERABLE — ownership checked after the fact, and the record already left the database
const invoice = await prisma.invoice.findUnique({ where: { id } });
if (!invoice) return notFound();
if (invoice.tenantId !== session.tenantId) return forbidden(); // 403 = existence oracle
return NextResponse.json(invoice);

Esa última también filtra: un 403 para "existe pero no es tuyo" y un 404 para "no existe" le dicen al atacante qué IDs son reales. Además carga la fila completa — incluidas columnas que la interfaz nunca muestra — en tu proceso, tus logs y tu APM antes de que se ejecute la comprobación.

Regla 1 — El alcance viene de la sesión, nunca de la petición

Si el cliente puede nombrar el alcance, el cliente es el dueño del alcance. Nunca leas tenantId, orgId, userId ni role de un cuerpo de petición, de un parámetro de query o de una cabecera personalizada con el fin de decidir el acceso. Esos valores vienen de la sesión (o del claim del JWT ya verificado) que produjo tu capa de autenticación. Todo lo que un cliente dice de sí mismo es una afirmación; solo la sesión del servidor es un hecho. Es el mismo principio que el de vincular la identidad del socket en el momento del upgrade en nuestra guía de endurecimiento de WebSocket.

Regla 2 — Mete el predicado de propiedad dentro de la consulta

La solución no es una segunda consulta, ni un if después del fetch, ni una anotación en el middleware. Es una cláusula WHERE. Haz que la base de datos sea incapaz de devolver una fila que quien llama no posee:

ts
// app/api/invoices/[id]/route.ts — SCOPED AT THE DATA LAYER
const invoice = await prisma.invoice.findFirst({
  where: {
    id,
    tenantId: session.tenantId, // the predicate that makes IDOR impossible
  },
  select: {
    id: true,
    number: true,
    totalCents: true,
    status: true,
    issuedAt: true,
    lines: { select: { description: true, amountCents: true } },
  },
});

if (!invoice) {
  return NextResponse.json({ error: "not found" }, { status: 404 });
}

return NextResponse.json(invoice);

Dos notas sobre cómo hacer esto en Prisma sin volver incómodo el código:

prisma
// schema.prisma — make the compound key legal so you can use findUnique on both fields
model Invoice {
  id         String   @id @default(cuid())
  tenantId   String
  number     String
  totalCents Int
  status     String
  issuedAt   DateTime
  ownerId    String

  lines InvoiceLine[]

  @@unique([id, tenantId]) // enables findUnique({ where: { id, tenantId } })
  @@index([tenantId, issuedAt])
}

Con @@unique([id, tenantId]) declarado, findUnique acepta el selector compuesto y sigue siendo tan rápido como una búsqueda por clave primaria, a la vez que es estructuralmente incapaz de cruzar inquilinos. Sin él usas findFirst, que es igual de correcto (ten en cuenta que compila a un escaneo con LIMIT 1 sobre el índice que definas).

Para las escrituras, prefiere las formas que devuelven un contador y comprueba el contador:

ts
// WRITE PATH — atomic, tenant-scoped, and it tells you whether it matched
const result = await prisma.invoice.updateMany({
  where: { id, tenantId: session.tenantId, status: "DRAFT" },
  data: { status: "SENT", sentAt: new Date() },
});

if (result.count === 0) {
  // Covers "doesn't exist", "not yours", and "already sent" — all 404 to the caller.
  return NextResponse.json({ error: "not found" }, { status: 404 });
}

updateMany/deleteMany con un where compuesto es check-and-act en una sola sentencia: la base de datos evalúa el predicado y aplica el cambio de forma atómica. Eso importa por el mismo motivo que el patrón de actualización atómica de nuestra guía de condiciones de carrera: un find seguido de un update es una ventana TOCTOU, y una comprobación de propiedad que ocurre en JavaScript es una comprobación que se puede ganar por carrera.

Regla 3 — Responde 404, no 403

Devuelve 404 Not Found tanto para "existe pero no puedes acceder" como para "no existe". Un 403 distinto confirma la existencia del objeto y convierte tu API en un oráculo de IDs: un atacante enumera el espacio de IDs, observa qué valores pasan de 404 a 403 y ya tiene una lista validada de todos los objetos reales, a menudo incluidos los que son sensibles precisamente porque los escondiste. Si tu producto necesita de verdad decirle a un cliente "no tienes permiso sobre esto" (enlaces compartidos, invitaciones de equipo), hazlo sobre objetos cuyo ID ya sea una capacidad en manos del usuario, nunca como un error genérico de autorización sobre IDs enumerables.

Mantén la distinción donde no cuesta nada: registra el motivo real en el servidor, con el usuario que actúa y el ID del objeto, y alerta ante 404 repetidos de una misma sesión recorriendo una secuencia. Esa es la señal de detección de IDOR que realmente tienes.

Regla 4 — Centraliza la autorización en un único punto de control

Las comprobaciones dispersas se pudren. Ruta nueva, sin comprobación; segunda implementación del endpoint de exportación, sin comprobación; un job en segundo plano que reutiliza el repositorio, sin comprobación. La estructura duradera es un único módulo por el que todo handler deba pasar:

ts
// lib/authz.ts — one module, one audit target
import { prisma } from "@/lib/db";
import type { Session } from "@/lib/auth";

type Action = "read" | "update" | "delete" | "share";

export async function authorizeInvoice(
  session: Session,
  invoiceId: string,
  action: Action,
) {
  const invoice = await prisma.invoice.findFirst({
    where: { id: invoiceId, tenantId: session.tenantId }, // scope is baked in here
    select: {
      id: true,
      ownerId: true,
      status: true,
      tenant: { select: { plan: true } },
    },
  });

  // Missing and not-yours collapse to the same answer. No oracle.
  if (!invoice) return null;

  const isOwner = invoice.ownerId === session.userId;
  const isAdmin = session.role === "admin";

  // Object-level (ownership) and role-level (vertical) rules in one place.
  if (action !== "read" && !isOwner && !isAdmin) return null;

  // Entitlement rules too: plan limits belong next to permission rules.
  if (action === "share" && invoice.tenant.plan === "starter") return null;

  return invoice;
}

El uso se queda en una línea, y el modo de fallo es un 404 sin divulgación de información:

ts
// app/api/invoices/[id]/route.ts
const invoice = await authorizeInvoice(session, id, "read");
if (!invoice) return NextResponse.json({ error: "not found" }, { status: 404 });

Después, haz imposible introducir el bypass por accidente: prohíbe el acceso directo al ORM fuera de la capa de repositorio con una regla de lint, y exporta solo los accesos autorizados al código de las rutas.

jsonc
// eslint.config.js — ban unscoped ORM calls outside lib/repos and lib/authz
{
  "files": ["app/**/*.{ts,tsx}", "src/app/**/*.{ts,tsx}"],
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "MemberExpression[property.name=/^findUnique$|^findFirst$|^update$|^delete$/]",
        "message": "Use an authorize* helper or a scoped repository function. Unscoped lookups by id are how IDOR ships."
      }
    ]
  }
}

La regla de lint es la diferencia entre una política y una práctica. Sin ella, la regla 4 es una convención que el siguiente colaborador externo no ha leído.

Regla 5 — El middleware no es una capa de autorización

middleware.ts es famoso por hacer bien una cosa: redirigir a los usuarios no autenticados fuera de grupos de rutas. No es un control a nivel de objeto, por tres razones concretas:

  1. Ve la ruta, no el objeto. matcher: ["/api/invoices/:path*"] se ejecuta antes de que el ID exista como hecho en la base de datos. Puede controlar "está autenticado", nunca "es propietario de inv_1042".
  2. Los matchers se desincronizan. Grupos de rutas nuevos, handlers internos importados directamente por otros handlers, endpoints de cron bajo /api/cron, receptores de webhooks — todo lo que quede fuera del matcher está sin proteger, y nada falla de forma ruidosa cuando añades una ruta que no encaja.
  3. Existen rutas de código que lo saltan. Las Server Actions y algunos fetch internos ejecutan su handler directamente; el middleware es una preocupación de la capa de enrutado, no una frontera de llamada a función. Si la autorización solo ocurre en el middleware, la autorización es opcional.

Usa el middleware para control de acceso grueso (autenticación requerida, locale, cabeceras de seguridad) y pon la decisión a nivel de objeto dentro del handler, en la ruta de código que realmente toca la fila.

Regla 6 — Los argumentos de Server Actions y RSC son input del cliente

Un argumento de una Server Action llega desde el navegador. React cifra los argumentos vinculados (bound arguments) para que no puedan manipularse en tránsito, pero el valor lo sigue eligiendo el cliente: un atacante llama a tu action con sus propios argumentos, exactamente igual que llamaría a un endpoint de API. Los Server Components tienen la misma exposición a la inversa: las props y los search params son input, y cualquier dato que un Server Component obtenga con un ID suministrado por el cliente debe acotarse antes de renderizarse y serializarse en el payload de RSC.

ts
// app/(app)/invoices/actions.ts
"use server";

import { requireSession } from "@/lib/auth";
import { authorizeInvoice } from "@/lib/authz";
import { prisma } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";

export async function deleteInvoice(formData: FormData) {
  const session = await requireSession();

  // Client input. It looks trusted because TypeScript says it is a string.
  const id = String(formData.get("id") ?? "");

  // Re-authorize INSIDE the action, on the resolved object.
  const invoice = await authorizeInvoice(session, id, "delete");
  if (!invoice) notFound();

  await prisma.invoice.deleteMany({
    where: { id: invoice.id, tenantId: session.tenantId },
  });

  revalidatePath("/invoices");
}

La regla es contundente: la action es un endpoint público. Trata cada parámetro como hostil y ejecuta la misma lectura acotada o la misma escritura acotada que ejecutarías en un route handler.

Regla 7 — Trae menos: select, no include

Traer de más es la mitad silenciosa del control de acceso roto. Una consulta bien acotada que después arrastra todo el grafo del objeto sigue filtrando, solo que hacia dentro: hacia la serialización, la caché y los logs.

ts
// OVER-EXPOSING — the tenant predicate is right, the field selection is not
const invoice = await prisma.invoice.findFirst({
  where: { id, tenantId: session.tenantId },
  include: {
    customer: true,     // every column: billing address, tax ID, internal notes
    apiKeys: true,      // never belongs in a client response
    auditLog: true,     // internal actor emails, IPs
    comments: true,     // sibling records with their own tenant semantics
  },
});

Sustituye cada include por una lista blanca explícita de select, y revisa que las colecciones anidadas tengan su propio predicado de inquilino. En GraphQL aplica la misma disciplina a nivel de campo: un resolver que devuelve Invoice no debe resolver invoice.customer para un espectador sin acceso a ese cliente — los resolvers de campo también son fronteras de autorización, que es el tema de nuestra guía de seguridad en GraphQL.

Defensa en profundidad: Row-Level Security en PostgreSQL

El alcance en la capa de aplicación es tu control principal y, como todo control principal, depende de que un humano se acuerde de escribirlo. Row-Level Security (RLS) convierte la base de datos en la segunda línea de defensa: incluso una consulta sin alcance no devuelve nada que la sesión no pueda ver.

sql
-- Enable RLS on tenant-scoped tables
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;  -- applies to the table owner too

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true))
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

Establece el inquilino en la conexión de cada petición, dentro de una transacción para que el ajuste no pueda filtrarse entre conexiones de un pool:

ts
// lib/db.ts — run the request inside a transaction with the tenant bound
export function withTenant<T>(
  tenantId: string,
  fn: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
  return prisma.$transaction(async (tx) => {
    // SET LOCAL scopes the setting to this transaction only.
    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
    return fn(tx);
  });
}

Tres advertencias operativas, porque una política RLS mal configurada es peor que ninguna:

  • El rol de base de datos de la aplicación no debe ser superusuario ni tener BYPASSRLS. Los superusuarios y (por defecto) los propietarios de tabla se saltan las políticas salvo que uses FORCE ROW LEVEL SECURITY.
  • RLS es una intención por transacción: si tu ORM o un job abre su propia conexión fuera de withTenant, current_setting está sin definir — y con missing_ok = true (el segundo argumento true de arriba) la política no coincide silenciosamente con nada.
  • Las migraciones, las herramientas de administración y los jobs de analítica necesitan un rol de bypass explícito y auditado. No amplíes el rol de la app para cubrirlos.

Endpoints por lotes y GraphQL: el mismo bug con más IDs

Las rutas de un solo objeto son donde se encuentra el bug. Los endpoints por lotes son donde escala:

ts
// VULNERABLE — nodes()/ids[] resolves anything, one missing predicate, 500 rows
const invoices = await Promise.all(
  ids.map((id) => prisma.invoice.findUnique({ where: { id } })),
);

Cualquier endpoint que acepte una lista de IDs — ?ids=, nodes(ids: [...]) de GraphQL, una interfaz node(id:) de Relay, una exportación masiva — multiplica el impacto de un predicado ausente por el tamaño de la lista. Aplican dos reglas:

  • Filtra la lista, no la valides. Una sola consulta con where: { id: { in: ids }, tenantId: session.tenantId } y compara el número devuelto con el número solicitado. Un desajuste no es un error que tragarse: es un evento de autorización que merece registro y alerta.
  • Un ID global no es una capacidad. Codificar en base64 Invoice:42 como gid://app/Invoice/42 no cambia nada sobre quién puede leerlo, y la convención de IDs globales de Relay ha convencido a más de un equipo de que los IDs opacos son una frontera de seguridad. No lo son; son un esquema de nombres.

Tests de regresión que detectan el IDOR automáticamente

La revisión manual no escala, y los escáneres solo encuentran esto si tienen dos cuentas. Una matriz de dos cuentas se ejecuta en CI y rompe el build cuando alguien añade una ruta sin alcance:

ts
// tests/authz/idor.test.ts
import { describe, it, expect } from "vitest";

const BASE = process.env.TEST_BASE_URL!;

describe("object-level authorization — invoices", () => {
  it("hides another tenant's invoice on read (404, not 403)", async () => {
    const { cookie: cookieA, invoiceId } = await seedAccount("acme");
    const { cookie: cookieB } = await seedAccount("globex");

    const res = await fetch(`${BASE}/api/invoices/${invoiceId}`, {
      headers: { cookie: cookieB },
    });

    // 404 confirms nothing about existence — no oracle.
    expect(res.status).toBe(404);
    // And the body must not leak any field of the record.
    expect(await res.text()).not.toMatch(/INV-|acme/i);
  });

  it("blocks the cross-tenant write path", async () => {
    const { cookie: cookieA, invoiceId } = await seedAccount("acme");
    const { cookie: cookieB } = await seedAccount("globex");

    const res = await fetch(`${BASE}/api/invoices/${invoiceId}`, {
      method: "DELETE",
      headers: { cookie: cookieB },
    });

    expect(res.status).toBe(404);
    expect(await invoiceStillExists(invoiceId)).toBe(true); // A's data is intact
    expect(await invoiceModifiedBy(invoiceId)).not.toBeTruthy();
  });

  it("keeps the owner's own access working", async () => {
    const { cookie: cookieA, invoiceId } = await seedAccount("acme");

    const res = await fetch(`${BASE}/api/invoices/${invoiceId}`, {
      headers: { cookie: cookieA },
    });

    expect(res.status).toBe(200); // never "secure" at the cost of correctness
  });

  it("does not leak through batch endpoints", async () => {
    const { invoiceId: a1 } = await seedAccount("acme");
    const { cookie: cookieB, invoiceId: b1 } = await seedAccount("globex");

    const res = await fetch(`${BASE}/api/invoices?ids=${a1},${b1}`, {
      headers: { cookie: cookieB },
    });

    const body = (await res.json()) as { id: string }[];
    expect(body.map((i) => i.id)).toEqual([b1]); // only own rows, silently filtered
  });
});

Ejecuta los mismos tres casos para lectura, actualización y borrado en cada recurso que tenga propietario: facturas, documentos, proyectos, ficheros, claves de API, configuraciones de webhooks. Dos hábitos convierten la batería en cobertura real:

  • Empareja cada ruta con un test. Mantén un mapa ruta-a-test y haz fallar CI cuando un fichero nuevo bajo app/api/** no tenga test entre inquilinos. Las superficies sin cobertura son exactamente donde aterriza el bug.
  • Prueba también las variantes suaves. Lecturas entre inquilinos a través de includes anidados, exportaciones e informes CSV (a menudo una segunda implementación de la misma consulta) y handlers de webhook que confían en un invoiceId del payload — la firma demuestra quién envía, no quién posee el objeto. Nuestra guía de condiciones de carrera y fallos de lógica de negocio cubre la clase relacionada en la que la comprobación existe pero la lectura y la escritura no son atómicas.

Trampas que reabren el agujero en silencio

  • Comprobar la propiedad después del fetch. La fila ya entró en tu proceso, tus logs y tus trazas. Acota en la consulta; un 403 después de cargar es una fuga con pasos extra.
  • La asimetría 403 vs 404. Una respuesta de prohibido distinta es un oráculo de existencia. Una única respuesta, siempre.
  • La ruta de escritura olvidada. Las lecturas reciben el test; delete y update heredan el bug porque "ya comprobamos al entrar".
  • Borrados lógicos sin filtrar. deletedAt no es una frontera de seguridad: una factura borrada sigue siendo una factura filtrada si una consulta no la excluye.
  • Sin predicado de inquilino en update/delete. Un where: { id } en una escritura es exactamente el mismo predicado ausente que en la lectura.
  • includes anidados sin alcance. El padre amarrado, los hijos abiertos. Revisa cada relación que toque tu serializador.
  • Lint y CI en verde, política ausente. Sin un módulo de punto de control y una regla que restrinja el ORM, la cuarta copia de la consulta no tiene ninguna restricción.
  • Respuestas autenticadas cacheadas en público. Cache-Control: public o un CDN delante de /api/ convierte una respuesta autorizada en una compartida. Usa private, no-store y varía según la cookie de sesión.
  • Comprobaciones de rol en el cliente. Un botón de admin oculto no es control de acceso. La escalada vertical se prueba llamando al endpoint directamente, que es exactamente lo que deberían hacer tus tests.
  • IDs globales tratados como capacidades. Opacos, en base64, estilo Relay: nada de eso es autorización.
  • Segundas implementaciones. La exportación, el informe, la consola de administración y el script interno vuelven a consultar los datos con sus propios filtros. Autoriza una vez y haz que los cuatro pasen por ahí.

El checklist del CTO

  • [ ] Todo handler que acepte un ID de objeto acota su consulta por el inquilino o el usuario de la sesión — findFirst({ where: { id, tenantId } }) o un findUnique compuesto. findUnique({ where: { id } }) a secas está prohibido.
  • [ ] Las rutas de escritura usan updateMany/deleteMany con el mismo predicado y comprueban count === 1 (o tratan 0 como 404).
  • [ ] "No es tuyo" y "no existe" devuelven el mismo estado y el mismo cuerpo. Ningún oráculo de existencia, en ningún endpoint.
  • [ ] La autorización vive en un único módulo auditable (lib/authz.ts), y una regla de lint prohíbe las llamadas al ORM sin alcance en el código de rutas y actions.
  • [ ] El middleware se usa solo para control grueso; las decisiones a nivel de objeto ocurren en el handler, en la ruta que toca la fila.
  • [ ] Cada Server Action, cada fetch de datos de RSC y cada handler de webhook trata sus IDs como input hostil del cliente y vuelve a autorizar sobre el registro resuelto.
  • [ ] Las respuestas usan listas blancas explícitas de select; las colecciones anidadas tienen su propio predicado de inquilino o no están.
  • [ ] RLS de PostgreSQL está activado en las tablas con inquilino; el rol de la app no es superusuario, no es propietario de la tabla y no tiene BYPASSRLS.
  • [ ] Las respuestas autenticadas son private, no-store y las cachés varían según la cookie de sesión.
  • [ ] Una matriz de dos cuentas (leer/actualizar/borrar × entre inquilinos × entre roles) cubre cada recurso y se ejecuta en CI; una ruta nueva sin test rompe el build.
  • [ ] Los 404 repetidos de una misma sesión recorriendo una secuencia de IDs disparan una alerta: esa es la señal de detección de IDOR que sí tienes.

Conclusión

El control de acceso roto no es exótico y no es un bug del framework. Es una cláusula WHERE que nunca se escribió, en un fichero donde todas las demás líneas parecen correctas. Next.js y Node.js lo ponen fácil de escribir: handlers finos, un ORM amable, params.id en un segmento dinámico, Server Actions que se leen como llamadas locales a funciones. Las contramedidas son igual de corrientes, y esa es la buena noticia: alcance derivado de la sesión, el predicado de propiedad dentro de la consulta, una única respuesta 404, un solo punto de control de autorización, selección explícita de campos, RLS como red y una batería de regresión con dos cuentas.

Si te quedas con una sola frase de este artículo, que sea esta: el ID del objeto es input, y la sesión es lo único que concede acceso a él.

Elige tu endpoint de mayor valor — el que devuelve dinero, datos personales o documentos — y revísalo ahora mismo: ¿la consulta contiene el predicado de inquilino, o el handler simplemente autentica y luego confía en el ID? Después avanza hacia fuera desde ahí: cada recurso, las dos rutas de escritura, las exportaciones y, por último, los tests que lo blindan todo.

¿Quieres un par de ojos con experiencia sobre tu control de acceso? Reserva una auditoría de seguridad — revisamos route handlers, Server Actions, políticas RLS y comportamiento de caché contra una matriz IDOR de dos cuentas, y te entregamos una lista priorizada de los objetos que tus usuarios pueden alcanzar hoy sin autorización.

La semana que viene: seguridad de Docker para despliegues de Node.js — imágenes base endurecidas, builds multi-stage sin secretos incrustados en las capas, usuarios de ejecución no root, sistemas de ficheros de solo lectura y por qué --privileged deshace todo lo que acabas de leer.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.