Autenticación JWT en Node.js: Guía Paso a Paso
El 80% de las brechas de seguridad en aplicaciones web involucran credenciales comprometidas o autenticación mal implementada (Verizon Data Breach Report, 2025). No estamos hablando de ataques sofisticados de estado-nación. Estamos hablando de tokens que nunca expiran, secrets hardcodeados en el código fuente y refresh tokens almacenados en localStorage donde cualquier script de terceros puede leerlos.
La autenticación es el pilar de seguridad más crítico de tu aplicación. Si falla, todo lo demás es irrelevante: tu base de datos cifrada, tu firewall, tus políticas de CORS. Un token JWT mal implementado es una puerta abierta.
En este artículo aprenderás:
- Cómo funciona JWT internamente: anatomía del token, firma y verificación
- Configuración completa del proyecto con Express, TypeScript y las dependencias correctas
- Registro y login seguros con hash de contraseñas usando bcrypt
- Middleware de autenticación para proteger rutas y extraer información del usuario
- Refresh tokens con rotación para sesiones seguras de larga duración
- Mejores prácticas de seguridad que separan un proyecto hobby de uno de producción
Cómo funciona JWT: anatomía de un token
Antes de escribir una sola línea de código, necesitas entender qué es exactamente un JWT y por qué se diseñó así. Sin esta base, vas a cometer errores que parecen funcionar en desarrollo pero explotan en producción.
Un JSON Web Token (JWT) es una cadena de texto codificada en Base64 que contiene tres partes separadas por puntos:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwiZW1haWwiOiJ1c3VhcmlvQGVqZW1wbG8uY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Esas tres partes son:
- Header: indica el algoritmo de firma (
HS256,RS256) y el tipo de token (JWT) - Payload: contiene los "claims" — datos del usuario como
userId,email,roley metadatos comoiat(issued at) yexp(expiration) - Signature: es el resultado de firmar
header + payloadcon un secret. Esto es lo que impide que alguien modifique el contenido del token
// Asi se ve el payload decodificado de un JWT tipico
{
"userId": "1234567890",
"email": "usuario@ejemplo.com",
"role": "admin",
"iat": 1716239022, // issued at: cuando se creo
"exp": 1716242622 // expires: cuando expira (1 hora despues)
}
El flujo de autenticación con JWT funciona así:
- El usuario envía sus credenciales (email + contraseña) al endpoint de login
- El servidor verifica las credenciales contra la base de datos
- Si son correctas, el servidor genera un JWT firmado con un secret y lo devuelve
- El cliente incluye ese JWT en el header
Authorizationde cada petición subsecuente - El servidor verifica la firma del JWT en cada petición protegida
Un error común es pensar que los datos dentro del JWT están cifrados. No lo están. Cualquier persona puede decodificar el payload con Base64. La firma solo garantiza que nadie modificó el contenido. Nunca pongas datos sensibles (contraseñas, números de tarjeta, tokens de API) dentro del payload de un JWT.
La ventaja principal de JWT sobre sesiones tradicionales es que es stateless: el servidor no necesita guardar el estado de la sesión en memoria o en una base de datos. Toda la información necesaria viaja dentro del propio token. Esto es especialmente útil en arquitecturas distribuidas donde múltiples servidores necesitan verificar la autenticación sin compartir estado.
Setup del proyecto: Express + TypeScript
Vamos a construir un sistema de autenticación completo desde cero. Empezamos con la estructura del proyecto y las dependencias.
# Crear el proyecto
mkdir auth-jwt-api && cd auth-jwt-api
npm init -y
# Dependencias de produccion
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
# Dependencias de desarrollo
npm install -D typescript @types/express @types/jsonwebtoken @types/bcryptjs @types/cookie-parser ts-node nodemon
Inicializa TypeScript con una configuración estricta:
npx tsc --init
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
La estructura de carpetas que vamos a usar sigue el patrón modular que describimos en nuestra guía de APIs REST con Node.js y TypeScript:
src/
├── config/
│ └── env.ts # Variables de entorno tipadas
├── middleware/
│ └── auth.middleware.ts # Middleware de verificacion JWT
├── modules/
│ └── auth/
│ ├── auth.controller.ts
│ ├── auth.service.ts
│ ├── auth.routes.ts
│ └── auth.types.ts
├── utils/
│ └── jwt.utils.ts # Funciones de generacion y verificacion
└── app.ts # Entry point
Ahora las variables de entorno. Este archivo es crítico porque aquí vive tu secret de JWT:
// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
jwtAccessSecret: process.env.JWT_ACCESS_SECRET || '',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || '',
jwtAccessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
jwtRefreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '12', 10),
nodeEnv: process.env.NODE_ENV || 'development',
corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:3000',
} as const;
// Validacion: el servidor NO debe arrancar sin secrets configurados
if (!config.jwtAccessSecret || !config.jwtRefreshSecret) {
console.error('FATAL: JWT secrets no configurados. Revisa tu archivo .env');
process.exit(1);
}
Y el archivo .env correspondiente:
# .env (NUNCA commitear este archivo)
PORT=3000
NODE_ENV=development
JWT_ACCESS_SECRET=tu-secret-de-al-menos-64-caracteres-generado-con-openssl-rand
JWT_REFRESH_SECRET=otro-secret-diferente-de-al-menos-64-caracteres
JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
BCRYPT_SALT_ROUNDS=12
CORS_ORIGIN=http://localhost:5173
La seguridad no es opcional
Agendar Diagnóstico →Registro y login: hash de contraseñas y generación de tokens
Con el proyecto configurado, vamos a implementar las piezas centrales: el modelo de usuario, el hash de contraseñas y la generación de tokens.
Tipos e interfaces
Primero definimos los tipos que usaremos en toda la aplicación:
// src/modules/auth/auth.types.ts
export interface User {
id: string;
email: string;
passwordHash: string;
role: 'admin' | 'user' | 'editor';
createdAt: Date;
updatedAt: Date;
}
export interface TokenPayload {
userId: string;
email: string;
role: User['role'];
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface RegisterInput {
email: string;
password: string;
role?: User['role'];
}
export interface LoginInput {
email: string;
password: string;
}
export interface RefreshTokenRecord {
id: string;
token: string;
userId: string;
expiresAt: Date;
createdAt: Date;
revoked: boolean;
}
Utilidades JWT
Estas funciones encapsulan la generación y verificación de tokens. Es importante mantenerlas separadas del servicio de autenticación para poder reutilizarlas y testearlas de forma independiente:
// src/utils/jwt.utils.ts
import jwt from 'jsonwebtoken';
import { config } from '../config/env';
import { TokenPayload } from '../modules/auth/auth.types';
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, config.jwtAccessSecret, {
expiresIn: config.jwtAccessExpiration,
issuer: 'tu-app-name',
audience: 'tu-app-client',
});
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, config.jwtRefreshSecret, {
expiresIn: config.jwtRefreshExpiration,
issuer: 'tu-app-name',
audience: 'tu-app-client',
});
}
export function verifyAccessToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, config.jwtAccessSecret, {
issuer: 'tu-app-name',
audience: 'tu-app-client',
}) as TokenPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('TOKEN_EXPIRED');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new Error('TOKEN_INVALID');
}
throw new Error('TOKEN_VERIFICATION_FAILED');
}
}
export function verifyRefreshToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, config.jwtRefreshSecret, {
issuer: 'tu-app-name',
audience: 'tu-app-client',
}) as TokenPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('REFRESH_TOKEN_EXPIRED');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new Error('REFRESH_TOKEN_INVALID');
}
throw new Error('REFRESH_TOKEN_VERIFICATION_FAILED');
}
}
Servicio de autenticación
El servicio contiene toda la lógica de negocio. En un proyecto real, el array users sería tu base de datos (PostgreSQL, MongoDB, etc.), pero la lógica de autenticación sería idéntica:
// src/modules/auth/auth.service.ts
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'crypto';
import { config } from '../../config/env';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../../utils/jwt.utils';
import {
User,
RegisterInput,
LoginInput,
AuthTokens,
TokenPayload,
RefreshTokenRecord,
} from './auth.types';
// En produccion: reemplaza con tu ORM (Prisma, TypeORM, Drizzle)
const users: User[] = [];
const refreshTokens: RefreshTokenRecord[] = [];
export async function registerUser(input: RegisterInput): Promise<{ user: Omit<User, 'passwordHash'>; tokens: AuthTokens }> {
// Verificar si el email ya existe
const existingUser = users.find((u) => u.email === input.email);
if (existingUser) {
throw new Error('EMAIL_ALREADY_EXISTS');
}
// Validar fortaleza de la contrasena
if (input.password.length < 8) {
throw new Error('PASSWORD_TOO_SHORT');
}
// Hash de la contrasena con bcrypt
const passwordHash = await bcrypt.hash(input.password, config.bcryptSaltRounds);
// Crear usuario
const newUser: User = {
id: crypto.randomUUID(),
email: input.email.toLowerCase().trim(),
passwordHash,
role: input.role || 'user',
createdAt: new Date(),
updatedAt: new Date(),
};
users.push(newUser);
// Generar tokens
const tokenPayload: TokenPayload = {
userId: newUser.id,
email: newUser.email,
role: newUser.role,
};
const tokens = createTokenPair(tokenPayload);
// Guardar refresh token en la base de datos
storeRefreshToken(tokens.refreshToken, newUser.id);
// Retornar usuario sin el hash de contrasena
const { passwordHash: _, ...userWithoutPassword } = newUser;
return { user: userWithoutPassword, tokens };
}
export async function loginUser(input: LoginInput): Promise<{ user: Omit<User, 'passwordHash'>; tokens: AuthTokens }> {
// Buscar usuario por email
const user = users.find((u) => u.email === input.email.toLowerCase().trim());
if (!user) {
// Mensaje generico para evitar enumeracion de usuarios
throw new Error('INVALID_CREDENTIALS');
}
// Verificar contrasena con bcrypt
const isPasswordValid = await bcrypt.compare(input.password, user.passwordHash);
if (!isPasswordValid) {
throw new Error('INVALID_CREDENTIALS');
}
// Generar tokens
const tokenPayload: TokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
};
const tokens = createTokenPair(tokenPayload);
storeRefreshToken(tokens.refreshToken, user.id);
const { passwordHash: _, ...userWithoutPassword } = user;
return { user: userWithoutPassword, tokens };
}
export async function refreshAccessToken(currentRefreshToken: string): Promise<AuthTokens> {
// Verificar que el refresh token es valido criptograficamente
const payload = verifyRefreshToken(currentRefreshToken);
// Verificar que el refresh token existe en la base de datos y no fue revocado
const storedToken = refreshTokens.find(
(rt) => rt.token === currentRefreshToken && !rt.revoked
);
if (!storedToken) {
// Si el token fue revocado, revocar TODOS los tokens del usuario
// Esto protege contra robo de refresh tokens
revokeAllUserTokens(payload.userId);
throw new Error('REFRESH_TOKEN_REVOKED');
}
// Revocar el token actual (rotacion de tokens)
storedToken.revoked = true;
// Generar nuevo par de tokens
const newTokenPayload: TokenPayload = {
userId: payload.userId,
email: payload.email,
role: payload.role,
};
const newTokens = createTokenPair(newTokenPayload);
storeRefreshToken(newTokens.refreshToken, payload.userId);
return newTokens;
}
export function logoutUser(refreshToken: string): void {
const storedToken = refreshTokens.find((rt) => rt.token === refreshToken);
if (storedToken) {
storedToken.revoked = true;
}
}
// --- Funciones internas ---
function createTokenPair(payload: TokenPayload): AuthTokens {
return {
accessToken: generateAccessToken(payload),
refreshToken: generateRefreshToken(payload),
};
}
function storeRefreshToken(token: string, userId: string): void {
refreshTokens.push({
id: crypto.randomUUID(),
token,
userId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 dias
createdAt: new Date(),
revoked: false,
});
}
function revokeAllUserTokens(userId: string): void {
refreshTokens
.filter((rt) => rt.userId === userId)
.forEach((rt) => { rt.revoked = true; });
}
Observa dos detalles críticos en el código anterior. Primero, en loginUser usamos el mismo mensaje de error INVALID_CREDENTIALS tanto si el email no existe como si la contraseña es incorrecta. Esto evita que un atacante pueda enumerar emails válidos probando diferentes combinaciones. Segundo, en refreshAccessToken implementamos detección de robo: si alguien intenta usar un refresh token que ya fue revocado, revocamos todos los tokens del usuario como medida de seguridad.
Middleware de autenticación: proteger rutas y extraer usuario
El middleware es la pieza que conecta todo. Se ejecuta antes de que la petición llegue a tu controller y decide si el usuario tiene permiso para acceder al recurso.
// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt.utils';
import { TokenPayload } from '../modules/auth/auth.types';
// Extender el tipo Request de Express para incluir el usuario
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Intentar obtener el token del header Authorization
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Token de autenticacion requerido',
});
return;
}
const token = authHeader.split(' ')[1];
if (!token) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Formato de token invalido',
});
return;
}
try {
const payload = verifyAccessToken(token);
req.user = payload;
next();
} catch (error) {
if (error instanceof Error) {
if (error.message === 'TOKEN_EXPIRED') {
res.status(401).json({
error: 'TOKEN_EXPIRED',
message: 'El token ha expirado. Usa tu refresh token para obtener uno nuevo.',
});
return;
}
}
res.status(401).json({
error: 'INVALID_TOKEN',
message: 'Token invalido o manipulado',
});
}
}
// Middleware de autorizacion por roles
export function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Autenticacion requerida',
});
return;
}
if (!allowedRoles.includes(req.user.role)) {
res.status(403).json({
error: 'FORBIDDEN',
message: 'No tienes permisos para acceder a este recurso',
});
return;
}
next();
};
}
El middleware requireRole es un patrón poderoso que puedes componer. Por ejemplo, puedes proteger un endpoint de administración así:
// Solo admin puede acceder
router.get('/admin/users', authMiddleware, requireRole('admin'), getUsers);
// Admin y editor pueden acceder
router.put('/posts/:id', authMiddleware, requireRole('admin', 'editor'), updatePost);
// Cualquier usuario autenticado puede acceder
router.get('/profile', authMiddleware, getProfile);
""
Controller y rutas: la API completa
Ahora conectamos todo en el controller y las rutas:
// src/modules/auth/auth.controller.ts
import { Request, Response } from 'express';
import * as authService from './auth.service';
export async function register(req: Request, res: Response): Promise<void> {
try {
const { email, password, role } = req.body;
if (!email || !password) {
res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Email y contrasena son requeridos',
});
return;
}
const result = await authService.registerUser({ email, password, role });
// Enviar refresh token como httpOnly cookie
res.cookie('refreshToken', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 dias
path: '/api/auth/refresh',
});
res.status(201).json({
user: result.user,
accessToken: result.tokens.accessToken,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === 'EMAIL_ALREADY_EXISTS') {
res.status(409).json({ error: 'CONFLICT', message: 'El email ya esta registrado' });
return;
}
if (error.message === 'PASSWORD_TOO_SHORT') {
res.status(400).json({ error: 'VALIDATION_ERROR', message: 'La contrasena debe tener al menos 8 caracteres' });
return;
}
}
res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Error al registrar usuario' });
}
}
export async function login(req: Request, res: Response): Promise<void> {
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'Email y contrasena son requeridos',
});
return;
}
const result = await authService.loginUser({ email, password });
res.cookie('refreshToken', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh',
});
res.status(200).json({
user: result.user,
accessToken: result.tokens.accessToken,
});
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_CREDENTIALS') {
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Credenciales invalidas' });
return;
}
res.status(500).json({ error: 'INTERNAL_ERROR', message: 'Error al iniciar sesion' });
}
}
export async function refresh(req: Request, res: Response): Promise<void> {
try {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Refresh token no proporcionado',
});
return;
}
const tokens = await authService.refreshAccessToken(refreshToken);
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh',
});
res.status(200).json({
accessToken: tokens.accessToken,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === 'REFRESH_TOKEN_REVOKED') {
// Limpiar la cookie
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
res.status(401).json({
error: 'TOKEN_REVOKED',
message: 'Sesion invalidada. Inicia sesion nuevamente.',
});
return;
}
}
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Refresh token invalido' });
}
}
export function logout(req: Request, res: Response): void {
const refreshToken = req.cookies?.refreshToken;
if (refreshToken) {
authService.logoutUser(refreshToken);
}
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
res.status(200).json({ message: 'Sesion cerrada correctamente' });
}
export function getProfile(req: Request, res: Response): void {
res.status(200).json({ user: req.user });
}
Las rutas conectan los endpoints con los controllers y el middleware:
// src/modules/auth/auth.routes.ts
import { Router } from 'express';
import * as authController from './auth.controller';
import { authMiddleware } from '../../middleware/auth.middleware';
const router = Router();
// Rutas publicas
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
// Rutas protegidas
router.get('/profile', authMiddleware, authController.getProfile);
export default router;
Y el entry point de la aplicación:
// src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import { config } from './config/env';
import authRoutes from './modules/auth/auth.routes';
const app = express();
// Middleware globales
app.use(cors({
origin: config.corsOrigin,
credentials: true, // Necesario para enviar cookies
}));
app.use(express.json({ limit: '10kb' })); // Limitar tamano del body
app.use(cookieParser());
// Rutas
app.use('/api/auth', authRoutes);
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Iniciar servidor
app.listen(config.port, () => {
console.log(`Servidor corriendo en http://localhost:${config.port}`);
console.log(`Entorno: ${config.nodeEnv}`);
});
export default app;
Autenticación robusta para tu proyecto
Agendar Diagnóstico →Refresh tokens: por qué un solo token no basta
Si solo usas un access token, tienes dos opciones malas:
- Token de vida corta (15 min): seguro, pero el usuario tiene que iniciar sesión cada 15 minutos. Terrible experiencia de usuario.
- Token de vida larga (30 días): cómodo, pero si alguien roba ese token tiene acceso completo durante un mes.
Los refresh tokens resuelven este dilema. El access token vive poco (15 minutos) y el refresh token vive más (7 días). Cuando el access token expira, el cliente usa el refresh token para obtener uno nuevo sin pedirle al usuario que escriba su contraseña de nuevo.
Pero hay un detalle crítico que muchos tutoriales omiten: la rotación de tokens.
Qué es la rotación de refresh tokens
Cada vez que el cliente usa un refresh token para obtener nuevos tokens, el servidor:
- Revoca el refresh token actual
- Genera un nuevo refresh token junto con el nuevo access token
- Devuelve ambos tokens nuevos
Esto significa que cada refresh token solo se puede usar una vez. Si un atacante roba un refresh token y lo intenta usar después de que el usuario legítimo ya lo usó, el servidor detecta que ese token ya fue revocado.
En nuestra implementación del servicio, esta lógica ya está incluida en la función refreshAccessToken. Revisa cómo al detectar un token revocado, revocamos todos los tokens del usuario:
// Fragmento de auth.service.ts - deteccion de robo de tokens
if (!storedToken) {
// Token ya fue revocado - posible robo detectado
// Medida defensiva: revocar TODOS los tokens del usuario
revokeAllUserTokens(payload.userId);
throw new Error('REFRESH_TOKEN_REVOKED');
}
Este patrón se llama Automatic Reuse Detection y es la recomendación de Auth0 y OWASP para implementaciones JWT en producción. Si un refresh token se usa dos veces, asumimos que uno de los dos usuarios (el legítimo o el atacante) tiene un token robado, y cerramos todas las sesiones como medida preventiva.
Flujo completo del cliente
Desde el lado del cliente (React, Vue, Angular), el flujo se ve así:
// Ejemplo de interceptor en el cliente (Axios)
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000/api',
withCredentials: true, // Enviar cookies automaticamente
});
// Interceptor que reintenta con refresh token si el access token expiro
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Si el error es 401 y no estamos ya reintentando
if (error.response?.status === 401
&& error.response?.data?.error === 'TOKEN_EXPIRED'
&& !originalRequest._retry) {
originalRequest._retry = true;
try {
// El refresh token viaja automaticamente como cookie httpOnly
const { data } = await api.post('/auth/refresh');
// Actualizar el access token en memoria
api.defaults.headers.common['Authorization'] = `Bearer ${data.accessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`;
// Reintentar la peticion original
return api(originalRequest);
} catch (refreshError) {
// Refresh token tambien invalido: redirigir a login
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Mejores prácticas de seguridad para producción
Hasta aquí tienes una implementación funcional. Pero para llevarla a producción necesitas aplicar capas adicionales de seguridad. Estas son las prácticas que implementamos en cada proyecto de MeepLab y que recomendamos como mínimo para cualquier API que maneje datos de usuarios.
1. Nunca guardes tokens en localStorage
localStorage es accesible por cualquier script de JavaScript que corra en tu página. Si tienes una vulnerabilidad XSS (Cross-Site Scripting) — y la probabilidad es más alta de lo que crees — un atacante puede leer todos los tokens almacenados ahí.
// MAL: tokens accesibles a cualquier script
localStorage.setItem('accessToken', token);
// BIEN: access token en memoria, refresh token en httpOnly cookie
// El access token vive solo en una variable JavaScript
let accessToken: string | null = null;
function setAccessToken(token: string) {
accessToken = token; // Solo en memoria, no persiste
}
// El refresh token viaja como cookie httpOnly
// (el navegador lo maneja automaticamente, JavaScript no puede leerlo)
El access token en memoria se pierde al cerrar la pestaña. Eso está bien: cuando el usuario vuelve a abrir tu app, el interceptor detecta que no hay access token, usa el refresh token (que viaja como cookie automáticamente) para obtener uno nuevo, y el usuario nunca nota la diferencia.
2. Expiración corta del access token
15 minutos es el estándar de la industria para access tokens. No uses 1 hora. No uses 24 horas. Si un access token es robado, el atacante tiene exactamente esa ventana de tiempo para usarlo. Con refresh tokens implementados correctamente, 15 minutos no afecta la experiencia del usuario.
3. Secrets fuertes y separados
# Generar secrets criptograficamente seguros
openssl rand -base64 64
# Ejemplo de output:
# k8Jd9f2L+mN4pQ7rS0tU3vW6xY9zA1bC4dE7fG0hI3jK6lM9nO2pQ5rS8tU1vW4xY7zA0bC3dE6fG9hI2jK5l
Usa un secret diferente para access tokens y refresh tokens. Si usas el mismo y se compromete, el atacante puede generar ambos tipos de tokens. Con secrets separados, comprometer uno no compromete el otro.
4. Configuración de CORS estricta
// En produccion, nunca uses cors() sin opciones
app.use(cors({
origin: ['https://tu-dominio.com'], // Solo tu dominio, no '*'
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
5. Rate limiting en endpoints de autenticación
Los endpoints de login y registro son objetivos principales de ataques de fuerza bruta. Implementa rate limiting específico para estos endpoints:
import rateLimit from 'express-rate-limit';
// Limitar intentos de login: 5 intentos por IP cada 15 minutos
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: {
error: 'RATE_LIMITED',
message: 'Demasiados intentos. Intenta de nuevo en 15 minutos.',
},
standardHeaders: true,
legacyHeaders: false,
});
// Limitar registros: 3 por IP por hora
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: {
error: 'RATE_LIMITED',
message: 'Demasiados registros desde esta IP. Intenta mas tarde.',
},
});
// Aplicar a las rutas
router.post('/login', loginLimiter, authController.login);
router.post('/register', registerLimiter, authController.register);
6. Helmet para headers de seguridad
import helmet from 'helmet';
app.use(helmet()); // Configura headers de seguridad automaticamente
// X-Content-Type-Options: nosniff
// X-Frame-Options: DENY
// Strict-Transport-Security: max-age=15552000
// Content-Security-Policy: default-src 'self'
Antes de llevar tu autenticación JWT a producción, verifica estos puntos críticos:
- Secrets generados con
openssl rand, no inventados a mano - Access token expira en 15 minutos o menos
- Refresh token almacenado como cookie httpOnly, secure, sameSite
- Rate limiting en
/loginy/register - CORS configurado solo para tu dominio (no
*) - Helmet habilitado para headers de seguridad
- Variables de entorno en un vault o secrets manager, no en el código
- Logs de intentos fallidos de autenticación para detección de ataques
Errores comunes en producción (y cómo evitarlos)
Después de revisar decenas de implementaciones JWT en auditorías de seguridad, estos son los errores que vemos con más frecuencia. Cada uno de ellos ha causado brechas reales en aplicaciones en producción.
Error 1: Tokens que nunca expiran
// MAL: sin expiracion
const token = jwt.sign({ userId: user.id }, secret);
// BIEN: siempre con expiracion explicita
const token = jwt.sign({ userId: user.id }, secret, { expiresIn: '15m' });
Un token sin expiración es válido para siempre. Si se filtra en un log, en un historial de navegador o en un cache de proxy, el atacante tiene acceso permanente. Siempre define expiresIn.
Error 2: Secret débil o hardcodeado
// MAL: secret predecible
const secret = 'mi-secret-super-seguro';
const secret = 'jwt-secret';
const secret = process.env.JWT_SECRET || 'default-secret'; // El default ES el problema
// BIEN: secret fuerte, sin fallback
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET no configurado');
}
Si tu secret es jwt-secret, un atacante puede generar tokens válidos para cualquier usuario de tu sistema. Usa secrets de al menos 64 caracteres generados con openssl rand.
Error 3: No validar el payload después de verificar
// MAL: confiar ciegamente en el payload
const payload = jwt.verify(token, secret);
const user = await db.users.findById(payload.userId);
// ¿Y si el usuario fue eliminado o desactivado despues de emitir el token?
// BIEN: verificar que el usuario sigue activo
const payload = jwt.verify(token, secret) as TokenPayload;
const user = await db.users.findById(payload.userId);
if (!user || user.isDeactivated) {
throw new Error('USER_NOT_FOUND_OR_DEACTIVATED');
}
Error 4: Enviar información sensible en el payload
// MAL: datos sensibles en el payload
const token = jwt.sign({
userId: user.id,
email: user.email,
password: user.password, // NUNCA
creditCard: user.creditCard, // JAMAS
ssn: user.ssn, // POR FAVOR NO
}, secret);
// BIEN: solo lo minimo necesario
const token = jwt.sign({
userId: user.id,
email: user.email,
role: user.role,
}, secret, { expiresIn: '15m' });
Recuerda: el payload de un JWT se puede leer con un simple atob() en la consola del navegador. Solo incluye identificadores y roles, nunca datos sensibles.
Error 5: Mismo endpoint para refresh sin rotación
// MAL: refresh sin revocar el token anterior
async function refresh(refreshToken: string) {
const payload = jwt.verify(refreshToken, refreshSecret);
return jwt.sign({ userId: payload.userId }, accessSecret, { expiresIn: '15m' });
// El refresh token original sigue siendo valido infinitamente
}
Sin rotación, un refresh token robado otorga acceso perpetuo. Implementa siempre la rotación y detección de reutilización que mostramos en la sección anterior.
Como discutimos en nuestra guía de arquitectura de software para PyMEs, las decisiones de seguridad que tomas al inicio del proyecto definen el costo de mantenerlo después. Corregir autenticación mal implementada en un sistema ya en producción es órdenes de magnitud más caro que hacerlo bien desde el principio.
Testing: verifica que tu autenticación funciona
No puedes deployar un sistema de autenticación sin tests. Estos son los tests mínimos que deberías tener:
// __tests__/auth.test.ts
import request from 'supertest';
import app from '../src/app';
describe('Auth Endpoints', () => {
let accessToken: string;
let refreshTokenCookie: string;
describe('POST /api/auth/register', () => {
it('debe registrar un usuario nuevo', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'test@ejemplo.com', password: 'Password123!' });
expect(res.status).toBe(201);
expect(res.body.user.email).toBe('test@ejemplo.com');
expect(res.body.accessToken).toBeDefined();
expect(res.body.user.passwordHash).toBeUndefined(); // No exponer hash
});
it('debe rechazar email duplicado', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'test@ejemplo.com', password: 'Password123!' });
expect(res.status).toBe(409);
});
it('debe rechazar contrasena corta', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'otro@ejemplo.com', password: '123' });
expect(res.status).toBe(400);
});
});
describe('POST /api/auth/login', () => {
it('debe autenticar con credenciales validas', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@ejemplo.com', password: 'Password123!' });
expect(res.status).toBe(200);
expect(res.body.accessToken).toBeDefined();
accessToken = res.body.accessToken;
// Capturar cookie de refresh token
const cookies = res.headers['set-cookie'];
refreshTokenCookie = cookies?.[0] || '';
expect(refreshTokenCookie).toContain('refreshToken');
expect(refreshTokenCookie).toContain('HttpOnly');
});
it('debe rechazar contrasena incorrecta', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@ejemplo.com', password: 'incorrecta' });
expect(res.status).toBe(401);
// Mensaje generico, no revelar si el email existe
expect(res.body.message).toBe('Credenciales invalidas');
});
});
describe('GET /api/auth/profile', () => {
it('debe retornar perfil con token valido', async () => {
const res = await request(app)
.get('/api/auth/profile')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
expect(res.body.user.email).toBe('test@ejemplo.com');
});
it('debe rechazar peticion sin token', async () => {
const res = await request(app).get('/api/auth/profile');
expect(res.status).toBe(401);
});
it('debe rechazar token manipulado', async () => {
const res = await request(app)
.get('/api/auth/profile')
.set('Authorization', 'Bearer token-invalido-manipulado');
expect(res.status).toBe(401);
});
});
});
Estos tests cubren los flujos críticos: registro exitoso y con errores, login correcto e incorrecto, acceso con y sin token, y manipulación de tokens. En un proyecto de producción, también deberías agregar tests para el flujo de refresh, logout y autorización por roles.
Si quieres profundizar en cómo estructurar tests para APIs Node.js, revisa nuestra guía sobre cómo integrar un LLM a tu aplicación Node.js donde cubrimos patrones de testing similares para servicios externos.
Conclusión
Implementar autenticación JWT en Node.js no es difícil. Implementarla bien requiere entender los vectores de ataque y las mejores prácticas que la industria ha desarrollado después de miles de brechas de seguridad.
Lo que cubrimos en esta guía:
- La anatomía de JWT y por qué la firma es lo que importa, no la codificación
- Un sistema completo con registro, login, middleware y autorización por roles
- Refresh tokens con rotación y detección automática de robo
- Mejores prácticas de seguridad: httpOnly cookies, rate limiting, secrets fuertes, helmet
- Errores comunes que causan brechas reales y cómo evitarlos
- Tests que verifican que la autenticación funciona correctamente
La seguridad no es una feature que agregas al final. Es una decisión de arquitectura que tomas al principio y que afecta cada capa de tu aplicación. Como mencionamos en nuestra guía de arquitectura para PyMEs, las decisiones técnicas tempranas definen el costo total de tu proyecto.
Construye sobre una base segura
Agendar Diagnóstico →Recursos relacionados
- Cómo Construir una API REST con Node.js y TypeScript -- La base sobre la que se construye este sistema de autenticación
- Cómo Integrar un LLM a Tu Aplicación con Node.js -- Patrones de integración avanzados con Node.js
- Arquitectura de Software para PyMEs: Guía Técnica -- Decisiones de arquitectura que afectan seguridad y escalabilidad
- OWASP JWT Security Cheat Sheet -- Referencia de seguridad de la industria
- Auth0: Refresh Token Rotation -- Documentación de referencia sobre rotación de tokens
