Saltar al contenido principal

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.

Fran Cobos 9 min de lectura 1664 palabras

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ónPor qué
httpOnly: true en cookieEl JS del navegador no puede leer el refresh token → previene XSS
secure: trueLa 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 loginNo revelas si un email existe → previene enumeración de usuarios
Rotación de refresh tokensSi 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/login para prevenir fuerza bruta
  • CORS configurado solo para dominios de confianza
  • Headers de seguridad (Helmet en Express)

Recursos relacionados

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.