Tema
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 mysql2env
JWT_ACCESS_SECRET=cambia-esto-por-64-caracteres-aleatorios
JWT_REFRESH_SECRET=otro-valor-distinto-y-largo
JWT_ACCESS_EXPIRA=15m
JWT_REFRESH_DIAS=7Genera 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.