La autenticación es uno de los pilares de cualquier aplicación web seria. JWT (JSON Web Tokens) es el estándar más utilizado para implementar autenticación sin estado (stateless) en APIs REST. En esta guía completa aprenderás cómo funciona JWT, cómo implementar un sistema de autenticación completo con Node.js y Express, y las mejores prácticas de seguridad para 2026.

¿Qué es JWT y cómo funciona?

JWT (JSON Web Token) es un estándar abierto (RFC 7519) que define una forma compacta y segura de transmitir información entre dos partes como un objeto JSON. Lo que hace especial a JWT es que esta información puede ser verificada y confiada porque está firmada digitalmente.

Un JWT tiene tres partes separadas por puntos: header.payload.signature. Veamos cada parte:

El flujo típico es: el usuario hace login → el servidor verifica las credenciales → el servidor genera un JWT firmado y lo devuelve → el cliente guarda el JWT → en cada petición, el cliente envía el JWT en el header → el servidor verifica la firma y extrae los datos del usuario sin consultar la base de datos.

Instalación y configuración del proyecto

Vamos a construir un sistema de autenticación completo desde cero. Primero, crea un proyecto Node.js e instala las dependencias necesarias:

mkdir auth-jwt-api
cd auth-jwt-api
npm init -y

# Dependencias principales
npm install express jsonwebtoken bcryptjs dotenv cors express-validator

# Dependencias de desarrollo
npm install --save-dev nodemon

Crea el archivo .env con las variables de entorno:

# .env
PORT=3000
JWT_SECRET=tu_clave_secreta_super_segura_minimo_32_caracteres
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=otra_clave_diferente_para_refresh_tokens
JWT_REFRESH_EXPIRES_IN=7d
NODE_ENV=development

Estructura del proyecto:

auth-jwt-api/
├── src/
│   ├── middleware/
│   │   ├── auth.middleware.js
│   │   └── validate.middleware.js
│   ├── routes/
│   │   ├── auth.routes.js
│   │   └── user.routes.js
│   ├── controllers/
│   │   └── auth.controller.js
│   ├── services/
│   │   ├── auth.service.js
│   │   └── token.service.js
│   ├── data/
│   │   └── users.js
│   └── app.js
├── .env
└── package.json

Implementando el servicio de tokens

// src/services/token.service.js
const jwt = require('jsonwebtoken');
require('dotenv').config();

const TokenService = {
  // Generar Access Token (vida corta)
  generateAccessToken(payload) {
    return jwt.sign(payload, process.env.JWT_SECRET, {
      expiresIn: process.env.JWT_EXPIRES_IN || '15m',
      issuer: 'turincondev-api',
      audience: 'turincondev-client'
    });
  },

  // Generar Refresh Token (vida larga)
  generateRefreshToken(payload) {
    return jwt.sign(
      { userId: payload.userId }, // Solo el ID en el refresh token
      process.env.JWT_REFRESH_SECRET,
      { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
    );
  },

  // Verificar Access Token
  verifyAccessToken(token) {
    try {
      return jwt.verify(token, process.env.JWT_SECRET, {
        issuer: 'turincondev-api',
        audience: 'turincondev-client'
      });
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        throw new Error('TOKEN_EXPIRED');
      }
      throw new Error('TOKEN_INVALID');
    }
  },

  // Verificar Refresh Token
  verifyRefreshToken(token) {
    try {
      return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
    } catch {
      throw new Error('REFRESH_TOKEN_INVALID');
    }
  },

  // Extraer datos sin verificar (útil para debug)
  decode(token) {
    return jwt.decode(token);
  }
};

module.exports = TokenService;

Base de datos de usuarios simulada

// src/data/users.js
const bcrypt = require('bcryptjs');

// Simulación de DB (en producción usarías MySQL, PostgreSQL, etc.)
const users = [];
const refreshTokensDB = new Set(); // En producción: Redis o DB

// Crear usuario admin de prueba al iniciar
async function seed() {
  const hash = await bcrypt.hash('Admin1234!', 12);
  users.push({
    id: 1,
    nombre: 'Adam Admin',
    email: 'admin@turincondev.com',
    password: hash,
    rol: 'admin',
    activo: true,
    creadoEn: new Date()
  });
}

seed();

module.exports = {
  users,
  refreshTokensDB,
  findByEmail: (email) => users.find(u => u.email === email),
  findById: (id) => users.find(u => u.id === id),
  create: (userData) => {
    const newUser = { ...userData, id: users.length + 1, creadoEn: new Date() };
    users.push(newUser);
    return newUser;
  }
};

Servicio de autenticación

// src/services/auth.service.js
const bcrypt = require('bcryptjs');
const TokenService = require('./token.service');
const DB = require('../data/users');

const AuthService = {
  async register({ nombre, email, password }) {
    // Verificar si el email ya existe
    if (DB.findByEmail(email)) {
      throw new Error('EMAIL_EXISTS');
    }

    // Hashear la contraseña (coste 12 es el equilibrio recomendado)
    const hashedPassword = await bcrypt.hash(password, 12);

    const newUser = DB.create({
      nombre,
      email,
      password: hashedPassword,
      rol: 'lector',
      activo: true
    });

    return { id: newUser.id, nombre: newUser.nombre, email: newUser.email };
  },

  async login({ email, password }) {
    const user = DB.findByEmail(email);

    // Mensaje genérico para no revelar si el email existe
    if (!user) throw new Error('INVALID_CREDENTIALS');
    if (!user.activo) throw new Error('ACCOUNT_DISABLED');

    const isValidPassword = await bcrypt.compare(password, user.password);
    if (!isValidPassword) throw new Error('INVALID_CREDENTIALS');

    const payload = { userId: user.id, email: user.email, rol: user.rol };
    const accessToken = TokenService.generateAccessToken(payload);
    const refreshToken = TokenService.generateRefreshToken(payload);

    // Guardar refresh token (en producción: Redis o DB)
    DB.refreshTokensDB.add(refreshToken);

    return {
      user: { id: user.id, nombre: user.nombre, email: user.email, rol: user.rol },
      accessToken,
      refreshToken,
      expiresIn: 900 // 15 minutos en segundos
    };
  },

  async refreshTokens(refreshToken) {
    if (!DB.refreshTokensDB.has(refreshToken)) {
      throw new Error('REFRESH_TOKEN_INVALID');
    }

    const decoded = TokenService.verifyRefreshToken(refreshToken);
    const user = DB.findById(decoded.userId);
    if (!user || !user.activo) throw new Error('USER_NOT_FOUND');

    // Rotar el refresh token (buena práctica de seguridad)
    DB.refreshTokensDB.delete(refreshToken);

    const payload = { userId: user.id, email: user.email, rol: user.rol };
    const newAccessToken = TokenService.generateAccessToken(payload);
    const newRefreshToken = TokenService.generateRefreshToken(payload);

    DB.refreshTokensDB.add(newRefreshToken);

    return { accessToken: newAccessToken, refreshToken: newRefreshToken };
  },

  logout(refreshToken) {
    DB.refreshTokensDB.delete(refreshToken);
  }
};

module.exports = AuthService;

Middleware de autenticación y autorización

// src/middleware/auth.middleware.js
const TokenService = require('../services/token.service');

// Verificar que el usuario está autenticado
const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      ok: false,
      error: 'Token de autenticación requerido'
    });
  }

  const token = authHeader.substring(7); // Quitar 'Bearer '

  try {
    const decoded = TokenService.verifyAccessToken(token);
    req.user = decoded; // El usuario ya decodificado disponible en la request
    next();
  } catch (error) {
    if (error.message === 'TOKEN_EXPIRED') {
      return res.status(401).json({
        ok: false,
        error: 'Token expirado',
        code: 'TOKEN_EXPIRED' // El frontend puede usar esto para hacer refresh automático
      });
    }
    return res.status(401).json({ ok: false, error: 'Token inválido' });
  }
};

// Verificar que el usuario tiene el rol requerido (autorización)
const authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user?.rol)) {
      return res.status(403).json({
        ok: false,
        error: 'No tienes permisos para acceder a este recurso'
      });
    }
    next();
  };
};

module.exports = { authenticate, authorize };

Controlador y rutas de autenticación

// src/controllers/auth.controller.js
const AuthService = require('../services/auth.service');

const AuthController = {
  async register(req, res) {
    try {
      const user = await AuthService.register(req.body);
      res.status(201).json({ ok: true, mensaje: 'Usuario registrado', datos: user });
    } catch (e) {
      if (e.message === 'EMAIL_EXISTS') {
        return res.status(409).json({ ok: false, error: 'El email ya está registrado' });
      }
      res.status(500).json({ ok: false, error: 'Error interno del servidor' });
    }
  },

  async login(req, res) {
    try {
      const resultado = await AuthService.login(req.body);

      // Enviar refresh token como cookie HTTP-only (más seguro que en el body)
      res.cookie('refreshToken', resultado.refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 días
      });

      res.json({
        ok: true,
        user: resultado.user,
        accessToken: resultado.accessToken,
        expiresIn: resultado.expiresIn
      });
    } catch (e) {
      if (e.message === 'INVALID_CREDENTIALS') {
        return res.status(401).json({ ok: false, error: 'Email o contraseña incorrectos' });
      }
      if (e.message === 'ACCOUNT_DISABLED') {
        return res.status(403).json({ ok: false, error: 'Cuenta desactivada' });
      }
      res.status(500).json({ ok: false, error: 'Error interno del servidor' });
    }
  },

  async refresh(req, res) {
    try {
      const refreshToken = req.cookies?.refreshToken;
      if (!refreshToken) {
        return res.status(401).json({ ok: false, error: 'Refresh token no encontrado' });
      }

      const tokens = await AuthService.refreshTokens(refreshToken);

      res.cookie('refreshToken', tokens.refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000
      });

      res.json({ ok: true, accessToken: tokens.accessToken });
    } catch {
      res.status(401).json({ ok: false, error: 'Refresh token inválido o expirado' });
    }
  },

  logout(req, res) {
    const refreshToken = req.cookies?.refreshToken;
    if (refreshToken) AuthService.logout(refreshToken);
    res.clearCookie('refreshToken');
    res.json({ ok: true, mensaje: 'Sesión cerrada correctamente' });
  },

  getProfile(req, res) {
    res.json({ ok: true, datos: req.user });
  }
};

module.exports = AuthController;
// src/routes/auth.routes.js
const express = require('express');
const router = express.Router();
const AuthController = require('../controllers/auth.controller');
const { authenticate } = require('../middleware/auth.middleware');

router.post('/register', AuthController.register);
router.post('/login', AuthController.login);
router.post('/refresh', AuthController.refresh);
router.post('/logout', AuthController.logout);
router.get('/profile', authenticate, AuthController.getProfile);

module.exports = router;

// src/routes/user.routes.js (rutas protegidas)
const express = require('express');
const router = express.Router();
const { authenticate, authorize } = require('../middleware/auth.middleware');

router.get('/dashboard', authenticate, (req, res) => {
  res.json({ ok: true, mensaje: 'Dashboard accesible para usuarios autenticados', user: req.user });
});

router.get('/admin', authenticate, authorize('admin'), (req, res) => {
  res.json({ ok: true, mensaje: 'Panel de admin solo para administradores' });
});

module.exports = router;

Servidor principal

// src/app.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser'); // npm install cookie-parser
const authRoutes = require('./routes/auth.routes');
const userRoutes = require('./routes/user.routes');

const app = express();

app.use(cors({ origin: 'http://localhost:4200', credentials: true }));
app.use(express.json());
app.use(cookieParser());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log('API corriendo en http://localhost:' + PORT));

Probando la API con ejemplos reales

# 1. Registrar un usuario
curl -X POST http://localhost:3000/api/auth/register   -H "Content-Type: application/json"   -d '{"nombre":"María García","email":"maria@ejemplo.com","password":"MiPassword123!"}'

# 2. Hacer login
curl -X POST http://localhost:3000/api/auth/login   -H "Content-Type: application/json"   -d '{"email":"admin@turincondev.com","password":"Admin1234!"}'   -c cookies.txt  # Guardar cookies

# 3. Usar el token para acceder a ruta protegida
curl http://localhost:3000/api/users/dashboard   -H "Authorization: Bearer TU_ACCESS_TOKEN_AQUI"

# 4. Refrescar el token cuando expira
curl -X POST http://localhost:3000/api/auth/refresh -b cookies.txt

# 5. Cerrar sesión
curl -X POST http://localhost:3000/api/auth/logout -b cookies.txt

Buenas prácticas de seguridad con JWT

  1. Access tokens de vida corta: 15 minutos es el estándar. Así si un token es robado, tiene una ventana de exposición muy pequeña.
  2. Refresh tokens en cookies HTTP-only: Nunca en localStorage (vulnerable a XSS). Las cookies HTTP-only no son accesibles desde JavaScript.
  3. Rotación de refresh tokens: Cada vez que usas un refresh token, genera uno nuevo e invalida el anterior. Detecta posibles robos de tokens.
  4. Clave secreta larga y aleatoria: Mínimo 32 caracteres aleatorios. Usa node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" para generarla.
  5. HTTPS siempre en producción: JWT sin HTTPS es peligroso. Los tokens viajan en cada petición y pueden ser interceptados sin cifrado.
  6. No guardes datos sensibles en el payload: El payload es Base64, no cifrado. Cualquiera puede decodificarlo. Guarda solo el ID y el rol, nunca contraseñas ni datos personales sensibles.
  7. Implementa una lista negra para logout: JWT es stateless, pero si necesitas invalidar tokens antes de que expiren (logout, cambio de contraseña), necesitas una lista negra en Redis.

Conclusión

Implementar autenticación con JWT en Node.js no es complicado si entiendes los conceptos correctamente. La clave es separar responsabilidades (servicio de tokens, servicio de auth, middleware), usar access tokens de vida corta con refresh tokens rotativos, y nunca guardar datos sensibles en el payload.

El sistema que hemos construido en esta guía es una base sólida que puedes adaptar a cualquier proyecto real. El siguiente paso es conectarlo a una base de datos real (MySQL o PostgreSQL) y añadir Redis para la gestión de refresh tokens en producción. ¿Tienes alguna duda sobre JWT o autenticación con Node.js? Déjala en los comentarios.