Saltar al contenido principal
Todos los casos de estudio

Full Stack · Seguridad · Identidad

Sistema de autenticación seguro

Un sistema de autenticación full stack con JWT: tokens de acceso de vida corta, tokens de refresco en cookies HttpOnly y protección de rutas por rol en ambos lados de la frontera de confianza.

Académico · Implementación de referencia
Rol
Ingeniero de Software — full stack
Periodo
2025

Etapas cubiertas

  • Arquitectura
  • Construcción

De un vistazo

2025

Backend

  • Node.js
  • Express
  • TypeScript
  • JSON Web Tokens

Frontend

  • React
  • Vite
  • TypeScript

Datos y ejecución

  • MySQL
  • Docker

Resumen

Un sistema de autenticación en contenedor construido para hacer bien los detalles y no para salir rápido: un token de acceso de vida corta guardado en memoria, un token de refresco que JavaScript no puede leer, renovación silenciosa, y roles de administrador y cliente aplicados en el servidor además de la interfaz.

Problema

La mayoría de implementaciones de autenticación falla en el mismo punto: el almacenamiento del token. Un JWT en localStorage es legible por cualquier script que llegue a la página, lo que convierte un solo bug de cross-site scripting en toma completa de la cuenta. Un token de vida larga lo empeora eliminando cualquier expiración natural del daño.

La segunda falla es más sutil. Ocultar un botón de administrador en la interfaz parece autorización y no lo es: es presentación. La verificación que importa es la del servidor, porque es la que un atacante no puede saltarse.

Restricciones

  • Asumir que el cross-site scripting es posible y diseñar para que no sea inmediatamente fatal
  • La continuidad de sesión no debe depender de que el usuario reingrese credenciales en cada expiración
  • Los roles deben aplicarse en la frontera de confianza, no solo reflejarse en la interfaz
  • Todo el stack debe correr de forma reproducible desde una sola definición de contenedor

Rol y contribución

Ingeniero de Software — full stack

  • Diseñé el flujo de autenticación de dos tokens y su modelo de almacenamiento
  • Implementé la emisión, verificación y rotación del lado de Express
  • Construí rutas de cliente protegidas con renovación transparente de token
  • Implementé la autorización por rol tanto en la API como en la interfaz
  • Contenericé el stack para ejecuciones locales reproducibles

Diseño del sistema

Arquitectura

Flujo de tokens y frontera de confianza

El cliente React envía las credenciales a la API de Express. La API devuelve un token de acceso de vida corta en el cuerpo de la respuesta, que el cliente guarda en memoria, y un token de refresco establecido como cookie HttpOnly que JavaScript no puede leer. Las peticiones protegidas llevan el token de acceso en una cabecera Authorization. Cuando expira, el cliente llama al endpoint de refresco; el navegador adjunta la cookie automáticamente y se emite un token de acceso nuevo sin intervención del usuario. Cada ruta protegida verifica el token y luego valida el rol del llamador contra el requisito de la ruta antes de correr el handler. Los registros de usuario viven en MySQL.

Backend
  • Node.js
  • Express
  • TypeScript
  • JSON Web Tokens
Frontend
  • React
  • Vite
  • TypeScript
Datos y ejecución
  • MySQL
  • Docker

Flujo de autenticación

El inicio de sesión devuelve dos credenciales con propiedades deliberadamente distintas. El token de acceso es de vida corta y llega en el cuerpo de la respuesta, así que el cliente lo mantiene en memoria y lo adjunta a las peticiones protegidas. El token de refresco es de vida larga y llega como cookie HttpOnly, así que sobrevive a una recarga pero es inalcanzable desde JavaScript.

Cuando una petición protegida falla porque el token de acceso expiró, el cliente llama al endpoint de refresco. El navegador adjunta la cookie automáticamente, el servidor emite un token de acceso fresco y se reintenta la petición original. El usuario no ve nada. Si el token de refresco también es inválido, la sesión termina y el usuario regresa al inicio de sesión.

  • Iniciar sesión → token de acceso corto en el cuerpo, token de refresco como cookie HttpOnly
  • Token de acceso en memoria y enviado como credencial bearer
  • Al expirar → refresco silencioso, luego reintentar la petición original
  • Ambos inválidos → termina la sesión, redirige al inicio de sesión

Dónde está la frontera de confianza

Las rutas de cliente protegidas existen por experiencia de usuario: evitan que alguien aterrice en una página cuyos datos no van a cargar. No son seguridad. Cada ruta protegida de la API verifica el token de forma independiente y valida el rol asociado antes de correr el handler.

Dicho llanamente: el frontend decide qué mostrar, el backend decide qué se permite. Cualquier cosa aplicada solo en el primero no está aplicada.

Manejo de errores

Los fallos de autenticación devuelven mensajes deliberadamente poco informativos. Un intento de inicio de sesión que falla no revela si la cuenta existe, porque la diferencia entre 'usuario inexistente' y 'contraseña incorrecta' es exactamente lo que abarata la enumeración de cuentas.

Internamente sí se preserva la distinción entre un token expirado y uno inválido, porque el cliente necesita saber si intentar un refresco o rendirse — pero esa señal es un código de estado, no una explicación.

Decisiones clave y trade-offs

Decisión

Mantener el token de acceso en memoria y el de refresco en cookie HttpOnly

Contexto
Los tokens tienen que sobrevivir al uso normal sin ser legibles por script inyectado.
Alternativas consideradas
  • Ambos tokens en localStorage — lo más simple, y legible por cualquier script de la página
  • Ambos tokens en cookies HttpOnly — a salvo del script, pero expuestos a falsificación de petición entre sitios
  • Sesiones del lado del servidor
Enfoque elegido
Token de acceso corto en memoria, token de refresco largo en cookie HttpOnly.
Razón
El cross-site scripting solo puede alcanzar el token de acceso, que expira rápido. La credencial que otorgaría acceso duradero es completamente inalcanzable desde JavaScript.
Trade-off
Una recarga de página pierde el token de acceso en memoria, así que la aplicación debe refrescar al arrancar, y el endpoint de refresco basado en cookie necesita su propia protección contra CSRF.

Decisión

Aplicar los roles en el servidor además de la interfaz

Contexto
Ocultar los controles de administrador en la interfaz ya evita la mayoría de accesos accidentales.
Alternativa considerada
  • Confiar en el renderizado condicional de rutas y controles solo para administradores
Enfoque elegido
Verificación de rol en middleware de la API, dejando las verificaciones del cliente puramente para experiencia de usuario.
Razón
Las verificaciones del lado del cliente son informativas — cualquiera puede llamar al endpoint directamente. La del servidor es la única que un atacante no puede saltarse.
Trade-off
La regla de rol queda expresada en dos lugares y ambos deben mantenerse de acuerdo.

Resultado

  • Un flujo funcional de dos tokens con renovación transparente y sin credenciales alcanzables desde los scripts de la página
  • Autorización por rol aplicada en la frontera de confianza y no en la presentación
  • Un stack reproducible en contenedor, tipado de punta a punta

Aprendizajes

  • El almacenamiento del token es la decisión que determina qué tan grave resulta un bug de cross-site scripting.
  • Los guards de ruta en el cliente son experiencia de usuario. La autorización es el middleware.
  • El refresco silencioso es donde viven los bugs sutiles: varias peticiones concurrentes que topan con un token expirado al mismo tiempo necesitan un solo refresco compartido, no uno cada una.
  • Los errores de autenticación vagos son una funcionalidad, y valen el pequeño costo en comodidad para el desarrollador.

Próximas mejoras

  • Rotación del token de refresco con detección de reuso, para que un token robado invalide la sesión
  • Protección explícita contra CSRF en el endpoint de refresco autenticado por cookie
  • Revocación del lado del servidor, que los JWT sin estado no proveen por sí solos
  • Límite de intentos y bloqueo en la ruta de inicio de sesión