Houdrik
Engineering
· 15 de mayo de 2025· 10 min read

Autenticación que sobrevive a una revisión OWASP

Un modelo de IA te genera un formulario de login en 90 segundos y va a parecer correcto. Lo que no va a hacer, ni siquiera con una librería reputada, es sobrevivir a una revisión de seguridad seria. Aquí va la lista.

Cover · auth-that-survives-an-owasp-pass

Un modelo de IA te genera un formulario de login funcional en 90 segundos. Va a elegir una librería reputada. Va a hashear contraseñas con el algoritmo correcto. El formulario renderiza, acepta credenciales, establece una cookie y redirige a algún sitio sensato. Para alguien no especialista leyendo el diff, parece terminado.

Lo que no va a hacer, ni siquiera con esa librería reputada, es sobrevivir a una revisión de seguridad seria. La lista de cosas que faltan consistentemente es corta, específica y casi idéntica en cada código generado por IA que hemos auditado el último año. Ninguno de estos fallos es exótico. Cada uno está a una búsqueda en OWASP. Sumados, son días de trabajo, y nadie agenda ese trabajo hasta que algo va mal.

1. Invalidación de sesión al cambiar contraseña

Cuando un usuario cambia su contraseña, toda sesión existente para ese usuario debería morir. No solo la actual — todas. Es el control más consistentemente olvidado que vemos. El comportamiento por defecto en la mayoría de frameworks rota el token CSRF de la sesión actual y se queda ahí. Las sesiones en otros dispositivos, otros navegadores, el portátil robado por el que el usuario está cambiando su contraseña — esas siguen funcionando.

El arreglo es pequeño y enteramente mecánico. Guarda un entero session_token_version en el registro del usuario. Inclúyelo en la cookie de sesión firmada. Incrementa al cambiar contraseña, al cambiar email, al hacer un "cerrar sesión en todas partes" explícito. Rechaza cualquier sesión cuya versión embebida no coincida con la actual. Eso es todo. Es un cambio de una tarde que cierra una clase entera de ataques a los que el prototipo estaba completamente expuesto.

2. Rate limiting en login y reset, con los defaults correctos

Un endpoint de login sin rate limit es un objetivo de credential stuffing desde el momento en que aparece en una lista. Un endpoint de reset sin rate limit es un arma de inundación de emails gratuita apuntada a tu proveedor transaccional. Los scaffolds de IA rara vez incluyen ninguno de los dos, y cuando lo hacen los límites son números arbitrarios elegidos sin referencia a qué hay detrás del endpoint.

La pregunta interesante es fail-open versus fail-closed. Si el limitador en sí no está disponible — Redis caído, contador en memoria reiniciado — ¿el endpoint acepta o rechaza la petición? El default correcto depende de qué hay detrás. Para un endpoint de login respaldado por un proveedor de identidad endurecido con su propia protección de fuerza bruta, fail-open es razonable. Para un flujo de reset propio que envía emails por un proveedor de pago, fail-closed es la única opción segura. Elige deliberadamente. Documenta la decisión al lado del limitador.

3. Prevención de enumeración de emails

Los flujos de reset y signup deben devolver la misma forma de respuesta exista o no el email en la base de datos. Mismo status code, mismo cuerpo, mismo timing dentro de un jitter razonable. Todo el mundo dice que lo hace. Casi nadie lo hace de verdad.

Las señales habituales: signup dice "email ya registrado", reset dice "te enviamos un enlace" solo si la cuenta existe y "email no encontrado" si no, los flujos de confirmación de cambio de contraseña muestran errores distintos según qué paso falló. Cada uno de esos es un oráculo de enumeración de usuarios gratis. El arreglo no es sutil — devolver la misma respuesta neutral en todos los casos, enviar el email de forma asíncrona para que el timing no sea una fuga, y poner la rama "¿enviamos algo?" entera del lado del servidor donde el atacante no puede observarla. El bug es que el ingeniero que escribe el formulario piensa que un mensaje de error más claro es una victoria de UX. Lo es, para el usuario legítimo. También publica tu lista de clientes.

4. Tokens CSRF acotados a las operaciones correctas

El default del framework es "requerir un token CSRF en cada POST". Eso es demasiado amplio y demasiado estrecho a la vez. Demasiado amplio porque montones de POSTs son públicos, idempotentes o ya están protegidos por otros medios, y la comprobación de token solo añade fricción. Demasiado estrecho porque las operaciones que realmente necesitan protección — cambio de contraseña, cambio de email, movimiento de fondos, asignación de roles, cualquier cosa que mute estado de una forma que a un atacante le importe — también viven en PATCH, PUT y DELETE. Los defaults del middleware no siempre los cubren uniformemente.

Qué lo aprieta: enumera explícitamente las operaciones que cambian estado, decora cada handler, y trata cualquier cosa fuera de la lista como una decisión deliberada y no un olvido. Combina la comprobación del token con SameSite=Lax en las cookies de sesión para los casos simples y SameSite=Strict para los sensibles. Audita cualquier endpoint que acepte un parámetro redirect_to y asegúrate de que la comprobación CSRF ocurre antes del redirect, no después. Esto es tedioso y nada glamoroso. También es donde viven la mayoría de vulnerabilidades CSRF reales.

5. Un modelo de permisos, una vez, consistente

El patrón por defecto en un código generado por IA son checks ad-hoc por ruta. if user.is_admin or user.id == resource.owner_id esparcidos por treinta funciones de vista. Funciona el día uno. Para el mes seis se ha podrido: una vista comprueba is_admin, otra comprueba is_staff, una tercera comprueba groups.filter(name='admin').exists(), y la diferencia entre ellas no está documentada porque nadie recuerda haberlo escrito así.

La forma correcta es una única capa de permisos por la que pase toda operación protegida. Llámalo RBAC, ABAC, un módulo de políticas, o simplemente una función can(user, action, resource) — elige uno, escríbelo, y haz que los checks por ruta sean una violación que los revisores rechacen en los PRs. El coste es una semana de consolidación al principio. El beneficio es que la próxima auditoría de seguridad encuentra la respuesta a "quién puede hacer X" leyendo un archivo en vez de buscando por treinta variantes de regex.

6. Aislamiento multi-tenant a nivel de base de datos, no de código

Este es el que peor envejece. El prototipo impone el aislamiento de tenants en el ORM: cada query filtra por tenant_id. El code review lo pilla. Los tests lo cubren. Luego alguien refactoriza, introduce un nuevo path de query a través de una tarea Celery, olvida el filtro, y las facturas de un tenant aparecen en el dashboard de otro.

Hay dos defensas que vale la pena tener. Row-level security en Postgres empuja la imposición a la base de datos: la conexión fija una variable de sesión, las políticas en cada tabla requieren que esa variable coincida con el tenant_id de la fila, y una query a la que le falte el filtro devuelve cero filas en lugar de los datos del tenant equivocado. El trade-off es operacional: cada conexión necesita la variable bien fijada, el connection pooling se vuelve más delicado, y depurar "¿por qué esta query no devuelve nada?" se convierte en un nuevo modo de fallo. La alternativa — queries acotadas con un tenant_id explícito en cada llamada — mantiene la complejidad en el código de aplicación donde es más fácil razonar sobre ella, pero depende de una disciplina que se erosiona el momento en que alguien nuevo entra al equipo. Elige uno. Nosotros vamos por defecto a RLS en cualquier cosa con más de dos tenants, porque el coste operacional está acotado y el coste de fuga de datos no.

El trabajo que nadie agenda

Cada uno de estos está a una búsqueda en OWASP. Ninguno requiere investigación nueva. Un ingeniero diligente con un checklist podría tacharlos en una semana o dos. La razón por la que no se hacen es que nadie agenda el trabajo. El formulario de login ya existe. Ya acepta logins. El product manager quiere la siguiente feature. La revisión de seguridad es teórica hasta que deja de serlo.

Hacemos este trabajo porque hemos sido el equipo de guardia cuando la revisión deja de ser teórica. Hay un tono particular que adopta un email de ingeniería a las 9 de la noche un miércoles cuando un investigador externo ha encontrado tres de los seis puntos de arriba en la misma tarde, y no es un tono que nadie quiera aprender una segunda vez.

Esto no es ingeniería glamorosa. Es el tipo de trabajo que cuesta un sprint hacer bien y que nadie celebra cuando se entrega, porque nada cambia visiblemente. También es el suelo por debajo del cual "production-grade" no significa gran cosa. Si la superficie de auth no ha sobrevivido a una revisión OWASP de verdad, la fiabilidad del resto del sistema es decoración.

¿Tienes una app que necesita durar?

Llévala de prototipo a producción.

Respondemos en un día laborable. MVP vibecoded, draft generado por IA, proyecto a medio terminar, o un producto funcionando que empieza a crujir — todo es bienvenido.

Iniciar un proyecto