DevLog#seguridad#next.js

Cómo encontramos 3 endpoints API críticos abiertos a internet (y cómo los cerramos)

En un audit interno descubrimos tres endpoints de admin públicos a internet sin validación. Cómo pasó, cómo los encontramos en diez minutos con un grep y los seis cambios concretos con los que los cerramos.

Franco Vassallo
Franco Vassallo
Head of Operations
5 de junio de 2026 9 min de lectura

Durante un audit interno de seguridad encontramos que tres endpoints API de un panel de administración estaban abiertos a internet sin ninguna validación de autenticación. Cualquiera con la URL podía hacer acciones destructivas sobre datos de la app. El bug pasaba silencioso en producción porque el frontend solo exponía esos endpoints detrás del login — pero esa "protección" era visual, no técnica. En este artículo contamos cómo lo encontramos, por qué pasó y los seis cambios concretos que aplicamos para que no vuelva a ocurrir.

3
endpoints sin auth detectados
0
días que tardamos en cerrarlos post-descubrimiento
~10
minutos para encontrarlos con un grep

El contexto: cómo llegamos a auditar

El audit nació de una intuición incómoda. Estábamos revisando un endpoint que enviaba credenciales y notamos que siempre devolvía 403 aunque el caller estuviera autenticado correctamente. Al rastrear el bug descubrimos que el validador buscaba en una tabla con un nombre legacy que ya no existía. Resultado: nadie podía usar la función, todo el mundo recibía 403, y nadie se daba cuenta porque el feature era poco usado.

Si ese endpoint estaba mal validado, ¿cuántos otros lo estaban? Un grep sobre los route handlers basta para listar candidatos sospechosos:

bash
# Lista API routes que NO llaman a ningun validador conocido
grep -rL "Authorization\|getUser\|verifyToken" src/api/

El comando lista todas las rutas que no llaman a ningún validador. En una app bien protegida esa lista debería contener solo endpoints públicos por diseño (contact form, cron jobs autenticados con secret, etc). En la lista del proyecto aparecían cosas que no deberían estar ahí.

El perfil de los tres endpoints

Sin entrar en qué hacía cada uno (lo único relevante es la clase de operación que permitían), todos compartían un mismo perfil: acciones administrativas sensibles que el frontend solo mostraba detrás del login, pero que el backend exponía sin ninguna validación. La clasificación general:

Endpoint A · Acción destructiva sobre datos

Recibía un identificador en el body y disparaba una operación destructiva irreversible sobre la entidad correspondiente. No validaba quién hacía la request. Un curl -X POST con cualquier ID bastaba para ejecutar la acción sobre cualquier registro.

Endpoint B · Lectura masiva de datos sensibles

Devolvía un listado completo sin filtros, con campos que servirían a un atacante (emails, identificadores, metadata, fechas). Para un scraper externo era una fuente perfecta de phishing dirigido, además de una huella reconocible del stack.

Endpoint C · Modificación de credenciales

Recibía un identificador y una nueva credencial, y la actualizaba. Diseñado para uso interno del admin, sin validación era un account takeover universal: pedir lista (endpoint B), elegir cuenta, modificar credencial, loguearse.

Por qué pasó (la causa real, no la superficial)

La causa superficial es obvia: "se olvidaron de validar". Pero eso no explica por qué pasó tres veces. La causa real es un patrón mental peligroso muy común cuando construís con Next.js App Router:

Si el frontend del admin renderiza el botón "Eliminar usuario" solo cuando el user está logueado como admin, entonces el endpoint detrás del botón también está protegido.

Eso es falso. El endpoint y el botón son dos cosas distintas. El endpoint vive en una URL pública del servidor. El botón es un <button> en un componente React que llama al endpoint. Quitar el botón no quita el endpoint. Ocultar la página tampoco. La única forma de proteger un endpoint es que el endpoint, en su código, valide al caller.

La trampa cognitiva aparece porque en el modelo mental el "admin panel" se siente como una caja cerrada. Pero el deploy no respeta cajas mentales — expone cada route file como una URL alcanzable desde internet.

Cómo los cerramos

El fix no fue parchar cada endpoint con un copy-paste de validación. Eso garantiza que el bug vuelva — la próxima vez que alguien cree un endpoint nuevo va a olvidarse de copiar el patrón. Lo que hicimos fue centralizar la validación en un helper reusable.

1. Helper único de autenticación

El primer paso fue centralizar la validación en un único módulo, con una función general de autenticación y atajos por rol. La idea: cada endpoint protegido arranca con un par de líneas idénticas, que delegan toda la lógica al helper. Algo así:

javascript
import { ensureAdmin } from '@/lib/auth'

export async function POST(request) {
  const auth = await ensureAdmin(request)
  if (!auth.ok) return auth.response
  // ... resto del endpoint
}

El helper extrae el Bearer token del header, lo valida contra el sistema de auth (que rechaza tokens expirados, malformados o revocados), busca el rol del usuario en una tabla de roles propia y devuelve { ok: false, response } si algo no cuadra. El endpoint solo necesita preguntar auth.ok — toda la lógica vive en un lugar único auditable.

2. Service role en el helper, no en cada endpoint

El helper usa la key de service role internamente para bypassar RLS al chequear el rol. Eso evita que cada endpoint instancie su propio cliente con la service key (con el riesgo de que alguien la deje expuesta por error). Hay un lugar en la app que toca service role, y es este.

3. Mensajes de error genéricos

Antes algunos endpoints devolvían "Token expirado" vs "Token inválido" vs "User sin rol". Eso le dice al atacante exactamente qué pasó y le permite afinar su ataque. Ahora todo es "No autorizado" (401) o "Sin permisos suficientes" (403). El detalle queda en logs del server, no en la respuesta.

4. Guard contra auto-modificación destructiva

Para acciones que podían dejar al admin operando en un estado roto (por ejemplo, borrarse o degradarse a sí mismo), agregamos un check explícito que rechaza el caso. Lo trivial pero importante:

javascript
if (auth.user.id === targetId) {
  return NextResponse.json(
    { error: 'No podés aplicarte esta accion a vos mismo' },
    { status: 400 }
  )
}

5. Bitácora de acciones administrativas

Cada acción destructiva ahora deja un registro en una bitácora inmutable, con los campos típicos: quién hizo la acción, qué acción, sobre qué objeto, metadata adicional y timestamp. La política de la tabla solo permite leer y agregar registros — nunca modificar ni borrar. La idea no es prevenir el bug, es poder reconstruir qué pasó cuando algo sale mal.

6. Rate limiting defensivo

Aunque la validación esté correcta, conviene agregar rate limit por IP a los endpoints sensibles. Si una validación rompe en algún refactor futuro, el rate limit al menos hace que la explotación sea lenta y ruidosa en logs.

Cómo revisar tu propia app en 10 minutos

Si tenés un proyecto Next.js + Supabase (o cualquier API con admin endpoints), corré este check. La gran mayoría de las apps que vimos tiene al menos un endpoint con este problema.

Característica
Comando o pasoCheck
Es un problemaSi encontrás esto
1. Listar routes sin validador
grep -rL "Authorization|getUser" en la carpeta de routesEndpoints en la lista que NO son públicos por diseño
2. Buscar uso directo de service role
grep -r "SERVICE_KEY|SERVICE_ROLE"Más de un archivo lo usa = riesgo
3. Probar sin auth
curl -X POST https://tuapp.com/api/admin/...Status 200 = endpoint abierto
4. Revisar mensajes de error
Probar con tokens inválidos vs expiradosRespuestas distintas = info disclosure
5. Verificar bitácora
¿Existe una tabla de log inmutable?No existe = sin trazabilidad de cambios destructivos
6. RLS en tablas sensibles
SELECT * FROM tabla con anon keyDevuelve filas = falta RLS o policy mal

Lo que aprendimos

Tres lecciones se nos quedaron de este episodio. Las anotamos porque suenan obvias en abstracto, pero la forma de internalizarlas es vivir el bug.

Primera: el rol de los frontends en seguridad es cero. El frontend puede esconder un botón para que el flujo sea más limpio, pero la seguridad real vive en el backend. Esto suena básico — y lo es — pero el bug recurrente es asumir que porque el botón no se ve, el endpoint no se llama. Cualquiera con curl y la URL prueba lo contrario.

Segunda: la centralización vence al copy-paste. Antes de tener un helper único, cada endpoint validaba a su manera. Resultado: tres errores distintos en tres endpoints distintos. Después del helper, agregar un endpoint protegido es imposible de olvidar — el código mismo te recuerda con dos líneas que tenés que poner siempre. La fricción de hacerlo mal es alta.

Tercera: los audits no son opcionales. Encontrar estos tres bugs llevó diez minutos. No corrimos un audit porque "teníamos sospechas" — corrimos un audit porque uno solo de los endpoints fallaba de forma rara. Si lo hubiéramos pospuesto, los otros dos seguían vivos. Vale la pena agendarse uno cada tres o seis meses, con o sin motivo aparente.

FAQ

Preguntas frecuentes

La validación tiene que estar en código del backend, no en el frontend ni en RLS solamente. Concretamente: el route handler debe extraer el JWT del header Authorization, validarlo contra Supabase Auth (que confirma firma + no expirado), y chequear el rol en una tabla propia. Si tu endpoint no llama explícitamente a getUser(token) o equivalente, está abierto.
Porque la mayoría de los endpoints usan service_role para sus operaciones (necesario para borrar usuarios, listar todos, etc.). Cuando usás service_role, las RLS no se aplican y los logs muestran la acción como hecha por el server, no por un user particular. Por eso es fundamental no depender solo de RLS para acciones admin — la validación tiene que estar antes, en el endpoint.
No. El middleware de Next.js puede proteger pages (impedir que /admin renderice si no hay sesión) pero los route handlers en /api ejecutan independientemente. Tenés que proteger ambos: middleware para las pages, validación inline para los endpoints. No es "uno o el otro" — son capas distintas que protegen cosas distintas.
Las server actions también necesitan validación explícita. La diferencia es que el endpoint generado es más opaco (Next.js le pone una URL random), pero igual está expuesto. Si alguien intercepta el call de tu admin panel, tiene la URL y puede reusar el action. Aplican las mismas reglas: validar JWT y rol dentro del action.
Tres capas combinadas. Una: hacé que tu helper de auth sea tan fácil de usar que sea más rápido protegerlo que no protegerlo (la idea es que sean dos líneas: ensureAdmin + check). Dos: linter o pre-commit hook que rechace route handlers en /api/admin que no importen el helper de auth. Tres: audit periódico con grep (diez minutos cada tres meses).
Para crear el helper centralizado y migrar 5-10 endpoints, calculá entre 4 y 8 horas según el estado del código. La parte que más tarda es el audit log y las migraciones de tablas si no existían — el código del validador en sí es chico. Si tu app tiene más de 20 endpoints admin, planificá una semana de trabajo dedicado.
Backend, sin discusión. El frontend puede ayudar (escondiendo botones, redirigiendo si no hay sesión), pero la responsabilidad de seguridad es del backend. En equipos donde "no hay backend separado" porque usan Next.js como full-stack, la responsabilidad cae en quien escribe los route handlers — esa persona tiene que pensar como backend dev al escribir un /api/, no como frontend dev.
Compartir este artículo
Publicado el 5 de junio de 2026
Franco Vassallo
Franco Vassallo
Head of Operations
Seguí leyendo