Crear una autenticación con JWT lista para producción en APIs de Node.js
Cómo hashear contraseñas, emitir JWTs de corta duración, agregar tokens de actualización y mecanismos de revocación, separar la autorización, resistir ataques de fuerza bruta y verificar que la autenticación falle correctamente.
Fuentes: JWT Authentication in Node.js: What I Learned Building Secure APIs
En un documento de requisitos, la autenticación se resume en una sola línea: el usuario inicia sesión con un correo electrónico y una contraseña, y la API concede acceso. En cuanto se implementa, esa línea se convierte en una docena de preguntas. ¿Dónde se almacenan las contraseñas y cómo se verifican? ¿Cómo reconoce la API al remitente en cada solicitud? ¿Qué sucede cuando un token expira, cómo funcionan los tokens de actualización y qué debe hacer el sistema tras una serie de intentos fallidos de inicio de sesión? Debajo de todas ellas se encuentra la pregunta más importante: ¿cómo evitar que el propio sistema de inicio de sesión se convierta en la parte más débil de la aplicación?
Esta guía aborda esas preguntas relacionadas con una API de Node.js y TypeScript respaldada por PostgreSQL, tratando la autenticación no como un único punto de acceso sino como una capa de seguridad que envuelve toda la aplicación. Al final, tendrá un modelo claro de cada capa, los problemas que pueden surgir en cada una y una lista de verificación para determinar cuándo el sistema está realmente listo para producción.
Almacene hashes de contraseñas, nunca las contraseñas mismas
La primera regla es que una contraseña no es un dato ordinario. Nunca debe guardarse en texto plano. Un registro de usuario como el que se muestra a continuación es inaceptable, ya que una sola fuga de datos del banco de datos expondría la contraseña de cada usuario, y dado que las personas reutilizan contraseñas, también expondría sus cuentas en otros lugares.
email: sachin@example.com
password: MyPassword123
En su lugar, ejecute cada contraseña a través de un algoritmo de hash de contraseñas deliberadamente lento antes de guardarla. Con el paquete bcrypt, importa la biblioteca:
import bcrypt from "bcrypt";
y hashea la contraseña durante el registro. El segundo argumento, 12, es el factor de costo: cada incremento duplica el trabajo requerido, lo que hace que adivinar fuera de línea contra una tabla de hash robada sea mucho más costoso, al mismo tiempo que mantiene una velocidad suficiente para un único inicio de sesión.
const passwordHash = await bcrypt.hash(password, 12);
Al iniciar sesión, no se descifra nada. El servidor toma la contraseña que acaba de enviar el usuario y pregunta a bcrypt si coincide con el hash almacenado. bcrypt.compare lee la sal y el costo incrustados en el hash, repite el cálculo y devuelve un valor booleano.
const isValid = await bcrypt.compare(
password,
user.passwordHash
);
La diferencia clave es que las contraseñas se hashean, no se cifran para su recuperación posterior. Una vez completado el registro, su aplicación nunca debería necesitar ni ser capaz de recuperar la contraseña original. Dos observaciones prácticas: bcrypt solo considera los primeros 72 bytes de la entrada, por lo que las frases de contraseña muy largas se truncan silenciosamente, y algoritmos como Argon2 son una alternativa común si desea una opción que requiera muchos recursos de memoria.
Trate el inicio de sesión como un flujo de trabajo, no como una consulta
Un endpoint típico de inicio de sesión sigue una secuencia de pasos, desde recibir la solicitud hasta la validación, búsqueda, comparación y creación del token:
Client
↓
POST /api/auth/login
↓
Validate input
↓
Find user
↓
Compare password
↓
Create authentication tokens
↓
Return authentication response
Lo importante es que cualquier paso puede fallar, y cada fallo requiere una respuesta deliberada. El correo electrónico podría estar mal formado; es posible que no haya ningún usuario que coincida con él. La contraseña podría estar incorrecta; la cuenta podría estar bloqueada. La base de datos podría ser inaccesible. Se trata de un flujo de trabajo sensible desde el punto de vista de la seguridad, y cada rama debe diseñarse, registrarse adecuadamente y probarse.
Qué es un JWT y qué no es
Los tokens web JSON son una forma común de representar una identidad autenticada en una API. Después de un inicio de sesión exitoso, el servidor emite un token firmado que describe al usuario. Una carga útil mínima podría contener solo un sujeto y un rol; sub es la claim estándar para el identificador del usuario.
{
"sub": "user-id",
"role": "user"
}
La carga útil está codificada, no encriptada, por lo que cualquiera que posea el token puede leerla. Consérvelo con identificadores y afirmaciones generales, nunca con secretos o datos personales. En las solicitudes posteriores, el cliente incluye el token en el encabezado Authorization utilizando el esquema Bearer:
Authorization: Bearer <access-token>
Antes de servir cualquier recurso protegido, el backend verifica la firma y la fecha de vencimiento del token. La secuencia general es la siguiente:
Login
↓
Credentials verified
↓
Access token issued
↓
Client sends token
↓
Server verifies token
↓
Protected API is allowed
La lección clave es que un JWT por sí solo no hace segura una aplicación. La seguridad depende igualmente de cómo se crea el token, dónde lo almacena el cliente, con qué rigor se valida (incluyendo la fijación del algoritmo de firma aceptado), cuándo vence, si se puede revocar y cómo viaja a través de la red (siempre por HTTPS).
Mantenga los tokens de acceso con vida corta
Los tokens de acceso no deben ser válidos indefinidamente. Si un token válido durante meses se filtra a través de un archivo de registro o de un dispositivo comprometido, un atacante puede utilizarlo durante meses.
El ejemplo a continuación utiliza jsonwebtoken para firmar un token que contiene el ID y el rol del usuario con una clave secreta obtenida del entorno, y establece expiresIn en quince minutos. El ! después de process.env.JWT_SECRET es la aserción no nula de TypeScript; en el código de producción es más seguro validar al inicio que la variable existe en lugar de hacerla en cada punto de llamada.
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role
},
process.env.JWT_SECRET!,
{
expiresIn: "15m"
}
);
La duración adecuada depende de los requisitos de seguridad, pero el principio es el mismo en todos los casos: limitar el tiempo durante el cual cualquier credencial que permite acceder a recursos protegidos permanece utilizable. Un período corto significa que un token robado se vuelve inútil rápidamente.
Los tokens de actualización resuelven el problema de utilidad
Si los tokens de acceso expiraran cada quince minutos, los usuarios se verían obligados a iniciar sesión constantemente, algo que nadie tolera. Los tokens de actualización solucionan ese problema. Al iniciar sesión, el cliente recibe dos credenciales: un token de acceso de corta duración que se utiliza en las llamadas a la API, y un token de actualización de mayor duración cuyo único propósito es obtener un nuevo token de acceso cuando el anterior expira.
┌─────────────────┐
│ Login │
└────────┬────────┘
↓
┌─────────────────┐
│ Access Token │
│ Short Lifetime │
└────────┬────────┘
│
API Requests
│
↓
Token expires
│
↓
┌─────────────────┐
│ Refresh Token │
│ Longer Lifetime │
└────────┬────────┘
↓
New Access Token
Esta división permite que los tokens de acceso sean cortos mientras las sesiones siguen pareciendo continuas. También introduce una nueva responsabilidad, ya que el token de actualización es ahora la credencial más valiosa. Debe almacenarse con cuidado (generalmente en una cookie httpOnly, Secure en la web y no en almacenamiento accesible desde JavaScript), enviarse únicamente al endpoint de actualización y ser validado estrictamente por el servidor. Para una discusión más profunda sobre el diseño, consulte nuestra guía sobre la estrategia de token de actualización para la autenticación en Node.js.
Plan de revocación
Una característica de los tokens autónomos que sorprende a muchos equipos es que siguen siendo válidos incluso después de que algo cambie en el servidor. Considere a un usuario que cierra su sesión, a un administrador que desactiva una cuenta, a un token de actualización cuya seguridad se ha visto comprometida, o a un incidente de seguridad que requiere que se cierren todas las sesiones. Si el diseño no cuenta con un mecanismo de revocación, eliminar o actualizar una fila en la base de datos no afecta en nada a un token que ya ha sido emitido; seguirá funcionando hasta su vencimiento.
Por esa razón, muchos sistemas de autenticación mantienen registros del lado del servidor de los tokens de actualización o sesiones, generalmente uno por dispositivo, asociados a cada usuario:
User
├── Refresh Token A
├── Refresh Token B
└── Refresh Token C
Con ese registro en su lugar, el servidor puede marcar tokens individuales como revocados, finalizar todas las sesiones de un usuario y negarse a generar nuevos tokens de acceso a partir de un token de actualización revocado. Esto revela un equilibrio necesario: los JWT reducen la cantidad de estado de sesión que debe mantener el servidor, pero la autenticación segura suele requerir aún algún tipo de estado del lado del servidor. Vidas útiles cortas para los tokens de acceso, junto con tokens de actualización revocables, son una forma común de equilibrar ambos aspectos.
Aplicar autenticación en el middleware
Repetir verificaciones de tokens en cada controlador es propenso a errores; olvidar una verificación deja un punto de acceso vulnerable. El middleware centraliza la lógica. En Express, una ruta protegida simplemente coloca una función authenticate antes de su controlador:
app.get(
"/api/profile",
authenticate,
profileController
);
Ese middleware tiene una tarea pequeña y bien definida:
- Leer el encabezado
Authorization.
El flujo de control resultante es fácil de comprender: un token inválido o faltante genera un 401, y solo uno válido llega al controlador.
Request
↓
Authentication Middleware
↓
Token valid?
↙ ↘
No Yes
↓ ↓
401 Controller
↓
Response
La autenticación y la autorización son verificaciones diferentes
Estas dos palabras se usan a menudo de forma intercambiable, pero responden a preguntas diferentes. La autenticación pregunta quién es el usuario que se conecta. La autorización pregunta qué puede hacer ese usuario. Nuestro artículo sobre dónde deben colocarse la autenticación y la autorización en su código explora este límite con más profundidad.
Imagínese una aplicación con tres roles:
Admin
Staff
User
Los miembros de los tres roles pueden autenticarse por completo, pero sus permisos difieren significativamente. A un usuario regular podría permitírsele leer su perfil, listar problemas y crear nuevos:
GET /api/profile
GET /api/issues
POST /api/issues
Un administrador, además, podría tener permiso para modificar cuentas de usuario y eliminar problemas:
PATCH /api/users/:id
DELETE /api/issues/:id
Tener un JWT válido solo demuestra la identidad; no otorga permisos para cada operación. Un backend seguro necesita ambas capas, típicamente un middleware de autenticación seguido de verificaciones de roles o permisos por ruta, e idealmente también verificaciones de propiedad, para que un usuario pueda editar su propio problema pero no el de otro.
Protegerse contra inicios de sesión fallidos repetidos
El diseño de autenticación debe tener en cuenta a los atacantes, no solo a los usuarios legítimos. Un script automatizado puede enviar intentos de contraseña indefinidamente a menos que algo lo detenga. La defensa más simple es un contador que activa un bloqueo temporal después de varios fallos consecutivos:
Attempt 1 → Failed
Attempt 2 → Failed
Attempt 3 → Failed
Attempt 4 → Failed
Attempt 5 → Failed
↓
Temporary lock
En la práctica, se combinarían varias medidas según los requisitos:
- Límite de frecuencia en las solicitudes al endpoint de inicio de sesión.
- Bloqueos temporales de la cuenta.
Elija con cuidado, ya que una medida de defensa puede convertirse en un vector de denegación de servicio. Si cualquiera puede bloquear una cuenta simplemente escribiendo mal su correo electrónico cinco veces, los atacantes pueden bloquear intencionadamente a usuarios reales. El objetivo no es solo bloquear tras intentos fallidos, sino hacer que los ataques automatizados sean costosos mientras los usuarios legítimos puedan seguir accediendo, por ejemplo combinando retrasos por cuenta con límites de velocidad por IP en lugar de bloqueos totales.
Nunca confíe en la identidad proporcionada por el cliente
Un patrón peligroso es creer en los datos solo porque el frontend los envió. Supongamos que el cuerpo de una solicitud indica el ID de usuario y el rol del remitente:
{
"userId": "123",
"role": "admin"
}
Nada impide que un cliente malintencionado edite esos campos. El backend nunca debe asumir que son válidos. La identidad debe provenir de credenciales que el servidor haya verificado por sí mismo, lo cual significa que el ID del usuario y su rol se obtienen del token validado (o de la base de datos utilizando el sujeto del token), y las verificaciones de permisos se basan en eso:
Request
↓
Verify JWT
↓
Read authenticated user ID
↓
Check permissions
↓
Access resource
El servidor es la autoridad final. Este principio se extiende mucho más allá de la autenticación: el cliente puede mejorar la experiencia ocultando botones que el usuario no puede usar, pero nunca se puede confiar en él para hacer cumplir las reglas de seguridad.
Evite filtrar información en los mensajes de error
La seguridad abarca detalles como la redacción de los mensajes de error. Compare una respuesta que indica que el correo electrónico no existe:
Email does not exist.
con una que se mantiene deliberadamente vaga:
Invalid email or password.
El primero le indica al atacante qué correos electrónicos están registrados, lo que permite la enumeración de cuentas y facilita el adivinamiento dirigido de contraseñas. El segundo le da al usuario legítimo suficiente información para intentarlo de nuevo sin necesidad de confirmar nada. Tenga en cuenta que la enumeración también puede ocurrir a través del tiempo de respuesta o mediante los puntos finales de registro y restablecimiento de contraseñas, por lo que se aplica la misma precaución en esos casos.
La regla más general también abarca los errores de la base de datos y las excepciones no manejadas. Los clientes necesitan un error con el que puedan actuar; no necesitan un rastro de pila, un mensaje SQL ni identificadores internos.
Mantenga los secretos fuera del código fuente
La autenticación depende de secretos como una clave de firma, una cadena de conexión a la base de datos y claves API de terceros:
JWT_SECRET
DATABASE_URL
API_KEY
Estos nunca deben ser comprometidos en un repositorio. Una clave codificada de esta manera representa un riesgo en el momento en que cualquier otra persona pueda leer el código:
const secret = "my-super-secret-key";
Leerla desde el entorno es la mejora mínima posible:
const secret = process.env.JWT_SECRET;
En producción, hay que ir más allá y suministrar los secretos a través de un sistema adecuado de configuración o gestión de secretos. Esto es especialmente importante en los repositorios públicos, donde una clave comprometida accidentalmente puede convertirse casi de inmediato en un incidente grave; si eso ocurre, se debe rotar el secreto en lugar de simplemente eliminar el commit.
Probar que la autenticación falla correctamente
Las pruebas manuales no son adecuadas para la autenticación, ya que los casos importantes son aquellos en los que no debería tener éxito. Un conjunto básico de pruebas automatizadas debe cubrir tanto los casos exitosos como los rechazados en cada etapa del flujo.
Registro
Las inscripciones válidas tienen éxito, mientras que los correos electrónicos mal formados, las entradas inválidas y las cuentas duplicadas son rechazadas:
Valid registration → Success
Invalid email → Rejected
Weak/invalid input → Rejected
Duplicate account → Rejected
Inicio de sesión
Las credenciales correctas funcionan; las contraseñas incorrectas, las cuentas desconocidas y las cuentas bloqueadas son denegadas:
Correct password → Success
Wrong password → Rejected
Unknown account → Rejected
Locked account → Rejected
Rutas protegidas
Los tokens faltantes, inválidos o vencidos generan cada uno un 401; un token válido permite el acceso, mientras que un token válido sin el rol requerido produce un 403. Tenga en cuenta la diferencia: 401 significa “no sabemos quién es usted”, mientras que 403 significa “sabemos quién es usted, y la respuesta es no.”
No token → 401
Invalid token → 401
Expired token → 401
Valid token → Allowed
Valid token + insufficient role → 403
Pruebas como estas van más allá de confirmar que la autenticación funciona. Demuestran que falla de la manera adecuada, que es donde se ocultan la mayoría de las vulnerabilidades reales.
Un modelo en capas de autenticación
Es útil imaginar la autenticación como una serie de capas separadas, cada una con una responsabilidad específica: desde las credenciales pasando por la verificación de contraseñas, la emisión de tokens, la validación y autorización de tokens, hasta el propio recurso protegido:
┌─────────────────────────────┐
│ User Credentials │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Password Verification │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Issuance │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Validation │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Authorization │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Protected Resource │
└─────────────────────────────┘
Alrededor de toda esta estructura existe una capa transversal que abarca el monitoreo, el registro de actividades, la limitación de frecuencia de solicitudes, la configuración segura y las pruebas.
Lista de verificación para estar listo para producción
Antes de considerar que un sistema de autenticación está completo, confirme que:
- Las contraseñas se hashean con un algoritmo fuerte y lento.
- Los tokens de acceso tienen una fecha de vencimiento adecuada y corta.
- Los tokens de actualización se almacenan, transportan y validan de manera segura.
- Cada ruta protegida valida la autenticación.
- La autorización se aplica como un paso separado de la autenticación.
Esta lista es una medida mucho más eficaz que simplemente verificar si el inicio de sesión funciona.
Puntos clave
La autenticación no es algo que se pueda añadir al final de un proyecto. Influencia en el diseño de la base de datos, la estructura de la API, los middleware, el comportamiento del frontend, el manejo de errores, el registro de actividades, las pruebas y el despliegue, y sobre todo, en la seguridad. Un punto de acceso para iniciar sesión requiere solo unas pocas líneas de código; en cambio, un sistema que funcione de manera segura ante usuarios reales, atacantes, tokens vencidos, solicitudes mal formadas y fallos en producción representa un problema de ingeniería mucho más complejo.
El siguiente área a estudiar de forma detallada es la seguridad de los tokens de actualización: dónde deben almacenarse, si y cómo rotarlos, cómo detectar el reuso de un token antiguo, cómo se lleva a cabo la revocación en la práctica y cómo debe funcionar el cierre de sesión en múltiples dispositivos. Cada una de estas decisiones sigue el mismo principio que todo lo anterior: la seguridad se mantiene cuando forma parte de cada elección de diseño, y no como una capa añadida posteriormente.
Lecturas relacionadas
- Implementación de tokens de acceso y refresh juntos en Node.js — Aprenda cómo combinar tokens de acceso de corta duración con tokens de refresh rotativos en Node.js para equilibrar la seguridad y sesiones de usuario fluidas.
- Sesiones vs. JWTs: Elegir el modelo de autenticación adecuado en Node.js — Conozca las diferencias reales entre sesiones y JWTs en la autenticación de Node.js, cuáles tienen limitaciones y cómo elegir entre ellos sin arrepentirse posteriormente.