Skip to content

Autenticación con JWT ​

Sistema de autenticación completo para APIs: registro, login, access token de vida corta (15 min) y refresh token de vida larga (7 días) en cookie httpOnly, rotación de refresh tokens, logout y middleware para proteger rutas y restringir por rol.

¿Por qué dos tokens? ​

  • Access token: va en la cabecera Authorization: Bearer .... Dura poco, así que si lo roban sirve unos minutos.
  • Refresh token: vive en una cookie httpOnly (JavaScript no puede leerla, lo que protege contra XSS). Sólo sirve para pedir un access token nuevo, y se guarda hasheado en la base de datos para poder revocarlo.

Instalación ​

bash
npm install jsonwebtoken bcrypt cookie-parser mysql2
env
JWT_ACCESS_SECRET=cambia-esto-por-64-caracteres-aleatorios
JWT_REFRESH_SECRET=otro-valor-distinto-y-largo
JWT_ACCESS_EXPIRA=15m
JWT_REFRESH_DIAS=7

Genera secretos seguros con: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))".

Tablas ​

sql
CREATE TABLE usuarios (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  email VARCHAR(150) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  rol ENUM('admin','cliente') NOT NULL DEFAULT 'cliente',
  creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE refresh_tokens (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  usuario_id INT UNSIGNED NOT NULL,
  token_hash CHAR(64) NOT NULL UNIQUE,
  expira_en DATETIME NOT NULL,
  creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE
) ENGINE=InnoDB;

Archivos ​

js
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
import { query } from '../db/mysql.js';

const DIAS = Number(process.env.JWT_REFRESH_DIAS ?? 7);

export function crearAccessToken(usuario) {
  // Sólo datos no sensibles: el payload de un JWT se puede leer (no está cifrado)
  return jwt.sign(
    { sub: usuario.id, rol: usuario.rol, nombre: usuario.nombre },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: process.env.JWT_ACCESS_EXPIRA ?? '15m' }
  );
}

export function verificarAccessToken(token) {
  return jwt.verify(token, process.env.JWT_ACCESS_SECRET); // lanza error si es inválido o expiró
}

const sha256 = (txt) => crypto.createHash('sha256').update(txt).digest('hex');

/** Crea un refresh token aleatorio y guarda su hash */
export async function crearRefreshToken(usuarioId) {
  const token = crypto.randomBytes(48).toString('hex');
  await query(
    'INSERT INTO refresh_tokens (usuario_id, token_hash, expira_en) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? DAY))',
    [usuarioId, sha256(token), DIAS]
  );
  return token;
}

/** Valida y CONSUME un refresh token (rotación: cada uno sirve una sola vez) */
export async function consumirRefreshToken(token) {
  const [fila] = await query(
    'SELECT id, usuario_id FROM refresh_tokens WHERE token_hash = ? AND expira_en > NOW()',
    [sha256(token)]
  );
  if (!fila) return null;
  await query('DELETE FROM refresh_tokens WHERE id = ?', [fila.id]);
  return fila.usuario_id;
}

export async function revocarRefreshToken(token) {
  await query('DELETE FROM refresh_tokens WHERE token_hash = ?', [sha256(token)]);
}

export const opcionesCookie = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production', // sólo HTTPS en producción
  sameSite: 'strict',
  path: '/api/auth',                             // la cookie sólo viaja a estas rutas
  maxAge: DIAS * 24 * 60 * 60 * 1000,
};
js
import bcrypt from 'bcrypt';
import { query } from '../db/mysql.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import {
  crearAccessToken, crearRefreshToken, consumirRefreshToken,
  revocarRefreshToken, opcionesCookie,
} from '../utils/tokens.js';

const publico = ({ password_hash, ...u }) => u; // nunca devuelvas el hash
// Hash de relleno para comparar cuando el usuario no existe
const HASH_FALSO = bcrypt.hashSync('contraseña-de-relleno', 12);

async function responderConTokens(res, usuario, status = 200) {
  const accessToken = crearAccessToken(usuario);
  const refreshToken = await crearRefreshToken(usuario.id);
  res.cookie('refresh_token', refreshToken, opcionesCookie);
  res.status(status).json({ accessToken, usuario: publico(usuario) });
}

export const registro = asyncHandler(async (req, res) => {
  const { nombre, email, password } = req.body;
  if (!nombre || !email || !password || password.length < 8) {
    return res.status(422).json({ error: 'Nombre, correo y contraseña (mín. 8) son obligatorios' });
  }
  const correo = email.toLowerCase().trim();
  const [existe] = await query('SELECT id FROM usuarios WHERE email = ?', [correo]);
  if (existe) return res.status(409).json({ error: 'Ese correo ya está registrado' });

  const hash = await bcrypt.hash(password, 12); // 12 rondas: buen equilibrio seguridad/velocidad
  const r = await query('INSERT INTO usuarios (nombre, email, password_hash) VALUES (?, ?, ?)', [nombre.trim(), correo, hash]);
  const [usuario] = await query('SELECT id, nombre, email, rol FROM usuarios WHERE id = ?', [r.insertId]);
  await responderConTokens(res, usuario, 201);
});

export const login = asyncHandler(async (req, res) => {
  const { email = '', password = '' } = req.body;
  const [usuario] = await query('SELECT * FROM usuarios WHERE email = ?', [email.toLowerCase().trim()]);

  // Compara aunque no exista el usuario, para que el tiempo de respuesta no revele nada
  const hash = usuario?.password_hash ?? HASH_FALSO;
  const ok = await bcrypt.compare(password, hash);
  if (!usuario || !ok) return res.status(401).json({ error: 'Credenciales incorrectas' });

  await responderConTokens(res, usuario);
});

export const refresh = asyncHandler(async (req, res) => {
  const token = req.cookies.refresh_token;
  if (!token) return res.status(401).json({ error: 'Sin sesión' });

  const usuarioId = await consumirRefreshToken(token);
  if (!usuarioId) {
    res.clearCookie('refresh_token', opcionesCookie);
    return res.status(401).json({ error: 'Sesión expirada, inicia sesión de nuevo' });
  }
  const [usuario] = await query('SELECT id, nombre, email, rol FROM usuarios WHERE id = ?', [usuarioId]);
  await responderConTokens(res, usuario);
});

export const logout = asyncHandler(async (req, res) => {
  const token = req.cookies.refresh_token;
  if (token) await revocarRefreshToken(token);
  res.clearCookie('refresh_token', opcionesCookie);
  res.status(204).end();
});

export const yo = asyncHandler(async (req, res) => {
  const [usuario] = await query('SELECT id, nombre, email, rol, creado_en FROM usuarios WHERE id = ?', [req.usuario.id]);
  res.json({ usuario });
});
js
import { verificarAccessToken } from '../utils/tokens.js';

/** Exige un access token válido. Deja los datos en req.usuario */
export function autenticar(req, res, next) {
  const cabecera = req.headers.authorization ?? '';
  const [tipo, token] = cabecera.split(' ');
  if (tipo !== 'Bearer' || !token) {
    return res.status(401).json({ error: 'Token requerido' });
  }
  try {
    const payload = verificarAccessToken(token);
    req.usuario = { id: payload.sub, rol: payload.rol, nombre: payload.nombre };
    next();
  } catch (err) {
    const expirado = err.name === 'TokenExpiredError';
    res.status(401).json({ error: expirado ? 'Token expirado' : 'Token inválido', codigo: expirado ? 'TOKEN_EXPIRADO' : 'TOKEN_INVALIDO' });
  }
}

/** Restringe por rol: autorizar('admin') */
export const autorizar = (...roles) => (req, res, next) => {
  if (!roles.includes(req.usuario?.rol)) {
    return res.status(403).json({ error: 'No tienes permiso para esta acción' });
  }
  next();
};
js
import { Router } from 'express';
import * as auth from '../controllers/auth.controller.js';
import { autenticar } from '../middlewares/auth.js';

const router = Router();

router.post('/registro', auth.registro);
router.post('/login', auth.login);
router.post('/refresh', auth.refresh);
router.post('/logout', auth.logout);
router.get('/yo', autenticar, auth.yo);

export default router;

Registrar en la app ​

js
// src/app.js
import cookieParser from 'cookie-parser';
import authRouter from './routes/auth.routes.js';
import { autenticar, autorizar } from './middlewares/auth.js';

app.use(cookieParser());
app.use('/api/auth', authRouter);

// Rutas protegidas
app.get('/api/admin/estadisticas', autenticar, autorizar('admin'), (req, res) => {
  res.json({ ventasHoy: 15230, usuario: req.usuario.nombre });
});

Cliente: renovar el token automáticamente ​

js
// frontend: guarda el access token sólo en memoria (no en localStorage)
let accessToken = null;

export async function api(url, opciones = {}) {
  const hacer = () => fetch(url, {
    ...opciones,
    credentials: 'include', // envía la cookie del refresh token
    headers: { 'Content-Type': 'application/json', ...opciones.headers,
               ...(accessToken && { Authorization: `Bearer ${accessToken}` }) },
  });

  let res = await hacer();
  if (res.status === 401) {
    // Intenta renovar una vez y repite la petición
    const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
    if (r.ok) {
      accessToken = (await r.json()).accessToken;
      res = await hacer();
    } else {
      window.location.href = '/login.html';
    }
  }
  return res;
}

export async function iniciarSesion(email, password) {
  const res = await fetch('/api/auth/login', {
    method: 'POST', credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error((await res.json()).error);
  const datos = await res.json();
  accessToken = datos.accessToken;
  return datos.usuario;
}

Error común

Guardar el JWT en localStorage. Cualquier script inyectado (XSS) puede leerlo. Mantén el access token en memoria y el refresh token en cookie httpOnly, como en este snippet.

Seguridad

  • Usa secretos distintos para access y refresh, largos y aleatorios.
  • Nunca pongas contraseñas, correos privados ni permisos detallados en el payload: cualquiera puede decodificarlo en jwt.io.
  • Combina el login con rate limit para frenar ataques de fuerza bruta.

Academia Web — aprende, copia y construye.