Solía pensar que escribir mi propia capa de autenticación era un motivo de orgullo. Si entiendes los JWT y puedes cifrar una contraseña, ¿qué tan difícil puede ser? Configuré un backend de Node, emití tokens y di el trabajo por terminado. El código funcionaba. Las pruebas pasaban. Luego empecé a leer sobre cómo ocurren los ataques reales y todo se desmoronó. Cada capítulo sobre inyección, enumeración y fugas por canales laterales me hacía volver a mi editor con una sensación de vacío en el estómago. Mi sistema de autenticación no solo tenía brechas; tenía cuatro puertas abiertas de par en par que yo mismo había instalado. Esto es lo que encontré y exactamente lo que cambié.
Inyección SQL mediante concatenación de cadenas
El primer error era el truco más viejo del manual. Estaba tomando la entrada del usuario e insertándola directamente en cadenas SQL. En mi ruta de inicio de sesión, tomaba el correo electrónico del cuerpo de la solicitud y lo concatenaba en una consulta como SELECT * FROM users WHERE email = '${email}'. Me parecía inofensivo porque yo controlaba el frontend. Esa es una suposición peligrosa. Un atacante no necesita tu frontend. Un solo cuerpo POST manipulado podría convertir esa comprobación de inicio de sesión en una filtración de datos o en una base de datos borrada. Envía un payload como ' OR '1'='1 o, peor aún, una consulta apilada que elimine tablas, y si la cadena se ejecuta sin procesar, tus datos desaparecerán. No le había dado a la base de datos ninguna forma de distinguir entre mi código y los datos del atacante.
La solución no fue más validación de entradas o escapar cadenas a mano. La verdadera solución fueron las consultas parametrizadas. Cambié a node-postgres y empecé a usar marcadores de posición como $1. La consulta se convierte en una plantilla: SELECT * FROM users WHERE email = $1. El controlador envía el SQL y los valores a través de canales separados. La base de datos trata la entrada estrictamente como datos, sin importar qué caracteres contenga. Este único cambio cierra toda la clase de ataques de inyección. Es más simple de leer, más fácil de mantener y elimina la carga de tener que ser un mago de las expresiones regulares cada vez que escribes una cláusula WHERE.
Enumeración de correos electrónicos mediante mensajes de error
Mi segundo error parecía una buena UX. Cuando un usuario escribía el correo electrónico incorrecto, devolvía User not found. Cuando acertaban el correo pero fallaban la contraseña, devolvía Incorrect password. Parecía útil. También era una herramienta de reconocimiento para los atacantes. Los scripts de enumeración pueden bombardear tu endpoint de inicio de sesión con miles de direcciones de correo electrónico. Si el cuerpo de la respuesta o el código de estado cambia dependiendo de si la cuenta existe, el script puede construir una lista verificada de tus usuarios. Esa lista se convierte en la base para ataques de credential stuffing, phishing dirigido y otros intentos de fuerza bruta.
Tuve que aceptar que, a veces, la facilidad de uso debe perder ante la seguridad. Cambié cada ruta de inicio de sesión fallida para que devolviera exactamente la misma cadena: Invalid credentials. Sin pistas. Sin lógica de ramificación en la respuesta de error. Ya sea que el correo falte, la contraseña sea incorrecta o la cuenta esté bloqueada, el texto permanece idéntico. Esto también se aplica a los flujos de registro y de restablecimiento de contraseña; no reveles si una dirección ya está en tu sistema. Un mensaje genérico elimina una fuga de información de la que dependen los atacantes.
Ataques de temporización en la comparación de contraseñas
El tercer error era invisible. Yo estaba
