Proteger tu API en Node.js con JWT: Guía Completa de Autenticación (2026)
Implementa autenticación JWT en Node.js paso a paso: login, refresh tokens, middleware de protección y buenas prácticas de seguridad. Con Express y NestJS.
Tabla de contenidos
Tu API está abierta. Cualquiera puede hacer curl https://tu-api.com/users y obtener todos los datos. Necesitas autenticación, y JWT es el estándar de facto para APIs REST.
El problema es que el 80% de los tutoriales de JWT que encuentras online tienen agujeros de seguridad: guardan tokens en localStorage, no implementan refresh tokens, o usan tiempos de expiración de 30 días.
Esta guía implementa autenticación JWT como se hace en producción.
Arquitectura
[Login] → POST /auth/login → { accessToken, refreshToken (cookie) }
│
[Petición protegida] → GET /users → Authorization: Bearer {accessToken}
│
[Token expirado] → POST /auth/refresh → Nueva pareja de tokens
Dos tokens, dos propósitos:
- Access token: Corta duración (15 min). Va en el header
Authorization. Autoriza peticiones. - Refresh token: Larga duración (7 días). Va en cookie
httpOnly. Solo sirve para obtener un nuevo access token.
Paso 1: Setup del proyecto
mkdir auth-api && cd auth-api
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser
npm install -D typescript @types/express @types/jsonwebtoken @types/bcryptjs tsx
// src/config.ts
export const config = {
accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!,
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET!,
accessTokenExpiry: '15m',
refreshTokenExpiry: '7d',
port: process.env.PORT || 3000,
} as const;
Genera secretos seguros con:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))". Nunca uses strings como"mi-secreto".
Paso 2: Generar y verificar tokens
// src/tokens.ts
import jwt from 'jsonwebtoken';
import { config } from './config';
interface TokenPayload {
userId: string;
email: string;
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, config.accessTokenSecret, {
expiresIn: config.accessTokenExpiry,
});
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, config.refreshTokenSecret, {
expiresIn: config.refreshTokenExpiry,
});
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, config.accessTokenSecret) as TokenPayload;
}
export function verifyRefreshToken(token: string): TokenPayload {
return jwt.verify(token, config.refreshTokenSecret) as TokenPayload;
}
¿Por qué dos secretos diferentes? Si alguien roba el access token secret, no puede generar refresh tokens (y viceversa). Minimizas el daño de una filtración.
Paso 3: Middleware de autenticación
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../tokens';
declare global {
namespace Express {
interface Request {
user?: { userId: string; email: string };
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token no proporcionado' });
}
const token = authHeader.split(' ')[1];
try {
const payload = verifyAccessToken(token);
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Token inválido o expirado' });
}
}
Paso 4: Rutas de autenticación
// src/routes/auth.ts
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../tokens';
const router = Router();
// Simulación de DB — en producción usa PostgreSQL, MongoDB, etc.
const users = new Map<string, { id: string; email: string; passwordHash: string }>();
// --- REGISTRO ---
router.post('/register', async (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email y contraseña requeridos' });
}
if (users.has(email)) {
return res.status(409).json({ error: 'El email ya está registrado' });
}
// Hash de contraseña — NUNCA guardes contraseñas en texto plano
const passwordHash = await bcrypt.hash(password, 12);
const id = crypto.randomUUID();
users.set(email, { id, email, passwordHash });
return res.status(201).json({ message: 'Usuario creado' });
});
// --- LOGIN ---
router.post('/login', async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = users.get(email);
if (!user) {
// Mensaje genérico — no reveles si el email existe o no
return res.status(401).json({ error: 'Credenciales inválidas' });
}
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Credenciales inválidas' });
}
const payload = { userId: user.id, email: user.email };
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// Refresh token en cookie httpOnly (inaccesible desde JS del navegador)
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // No accesible desde JavaScript
secure: true, // Solo HTTPS
sameSite: 'strict', // Protección CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 días
path: '/auth/refresh', // Solo se envía a esta ruta
});
return res.json({ accessToken });
});
// --- REFRESH TOKEN ---
router.post('/refresh', (req: Request, res: Response) => {
const token = req.cookies?.refreshToken;
if (!token) {
return res.status(401).json({ error: 'Refresh token no proporcionado' });
}
try {
const payload = verifyRefreshToken(token);
const newPayload = { userId: payload.userId, email: payload.email };
// Rotación de tokens: genera AMBOS de nuevo
const newAccessToken = generateAccessToken(newPayload);
const newRefreshToken = generateRefreshToken(newPayload);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
});
return res.json({ accessToken: newAccessToken });
} catch {
// Refresh token inválido o expirado — el usuario debe hacer login de nuevo
res.clearCookie('refreshToken');
return res.status(401).json({ error: 'Sesión expirada, inicia sesión de nuevo' });
}
});
// --- LOGOUT ---
router.post('/logout', (_req: Request, res: Response) => {
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.json({ message: 'Sesión cerrada' });
});
export default router;
Detalles de seguridad clave
| Decisión | Por qué |
|---|---|
httpOnly: true en cookie | El JS del navegador no puede leer el refresh token → previene XSS |
secure: true | La cookie solo se envía por HTTPS → previene man-in-the-middle |
sameSite: 'strict' | La cookie no se envía en peticiones cross-origin → previene CSRF |
path: '/auth/refresh' | La cookie solo se envía al endpoint de refresh → minimiza exposición |
| Mensajes genéricos en login | No revelas si un email existe → previene enumeración de usuarios |
| Rotación de refresh tokens | Si roban un refresh token viejo, ya no sirve → reduce ventana de ataque |
Paso 5: Servidor principal
// src/index.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { config } from './config';
import authRoutes from './routes/auth';
import { authenticate } from './middleware/auth';
const app = express();
app.use(express.json());
app.use(cookieParser());
// Rutas públicas
app.use('/auth', authRoutes);
// Rutas protegidas — necesitan access token válido
app.get('/me', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.get('/dashboard', authenticate, (req, res) => {
res.json({ message: `Bienvenido, ${req.user!.email}` });
});
app.listen(config.port, () => {
console.log(`API corriendo en http://localhost:${config.port}`);
});
Flujo completo
# 1. Registrar usuario
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "dev@example.com", "password": "S3gur0!2026"}'
# 2. Login → recibe accessToken + refreshToken en cookie
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "dev@example.com", "password": "S3gur0!2026"}' \
-c cookies.txt
# Respuesta: { "accessToken": "eyJhbGc..." }
# 3. Petición protegida con el access token
curl http://localhost:3000/me \
-H "Authorization: Bearer eyJhbGc..."
# 4. Cuando expira → refresh
curl -X POST http://localhost:3000/auth/refresh \
-b cookies.txt -c cookies.txt
# Respuesta: { "accessToken": "eyJhbGc...(nuevo)" }
# 5. Logout
curl -X POST http://localhost:3000/auth/logout -b cookies.txt
Versión NestJS (Guards)
Si usas NestJS (como en nuestro caso real con Atrapaclientes), la autenticación se implementa con Guards:
// auth/jwt.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Token no proporcionado');
}
try {
const token = authHeader.split(' ')[1];
const payload = this.jwtService.verify(token);
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Token inválido o expirado');
}
}
}
// users/users.controller.ts
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt.guard';
@Controller('users')
export class UsersController {
@Get('me')
@UseGuards(JwtAuthGuard)
getProfile(@Req() req) {
return { user: req.user };
}
}
Errores comunes de seguridad (y cómo evitarlos)
1. Guardar JWT en localStorage
// ❌ NUNCA hagas esto
localStorage.setItem('token', accessToken);
// Un ataque XSS puede leer localStorage y robar el token
// ✅ Guarda el access token en memoria (variable JS)
let accessToken = null;
async function login(email, password) {
const res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include', // Para recibir la cookie httpOnly
});
const data = await res.json();
accessToken = data.accessToken; // Solo en memoria
}
2. Access token con expiración larga
// ❌ Expiración de 30 días — si roban el token, tienen acceso un mes
jwt.sign(payload, secret, { expiresIn: '30d' });
// ✅ Expiración corta (15 min) + refresh token para renovar
jwt.sign(payload, secret, { expiresIn: '15m' });
3. No hashear contraseñas (o usar MD5/SHA)
// ❌ MD5 — se crackea en segundos con rainbow tables
const hash = md5(password);
// ❌ SHA256 — más seguro que MD5 pero no tiene salt automático
const hash = crypto.createHash('sha256').update(password).digest('hex');
// ✅ bcrypt — salt automático, computacionalmente costoso
const hash = await bcrypt.hash(password, 12);
4. Revelar si un email existe en el login
// ❌ Revela que el email existe
if (!user) return res.status(404).json({ error: 'Usuario no encontrado' });
if (!validPassword) return res.status(401).json({ error: 'Contraseña incorrecta' });
// ✅ Mensaje genérico para ambos casos
return res.status(401).json({ error: 'Credenciales inválidas' });
Checklist de seguridad para tu API
- Secretos JWT generados con
crypto.randomBytes(64) - Access token con expiración <= 15 minutos
- Refresh token en cookie
httpOnly,secure,sameSite - Contraseñas hasheadas con bcrypt (rounds >= 12)
- HTTPS obligatorio en producción
- Mensajes de error genéricos en login
- Rotación de refresh tokens
- Rate limiting en
/auth/loginpara prevenir fuerza bruta - CORS configurado solo para dominios de confianza
- Headers de seguridad (Helmet en Express)
Recursos relacionados
- Error 429 Too Many Requests: cómo manejar rate limiting — aplica también a tus endpoints de login
- Por qué NestJS sobre Express para un SaaS — Guards, interceptores y módulos para auth escalable
- Caso real SaaS Atrapaclientes — autenticación JWT en un producto real con multi-tenancy