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.
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:
# 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í:
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:
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 routes | Endpoints 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 expirados | Respuestas 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 key | Devuelve 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.
