Tema
Autenticación
Casi toda app real necesita usuarios: registro, inicio de sesión y rutas protegidas. En esta lección implementarás autenticación segura con bcrypt para las contraseñas, JWT para identificar al usuario y cookies httpOnly para guardar el token sin exponerlo a JavaScript.
Concepto
- Autenticación: ¿quién eres? (iniciar sesión).
- Autorización: ¿qué puedes hacer? (roles y permisos).
Contraseñas: nunca en texto plano
Se guarda un hash: una huella irreversible de la contraseña. Al iniciar sesión, se calcula el hash de lo que escribió el usuario y se compara. bcrypt además agrega una sal aleatoria y es lento a propósito, para que adivinar millones de contraseñas sea impráctico.
txt
"miPassword123" ──bcrypt──► $2b$12$Kq3v9yX...Z8a (60 caracteres)JWT: el "gafete" del usuario
Un JSON Web Token es un texto firmado con tres partes separadas por puntos:
txt
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI0MiIsInJvbCI6ImFkbWluIn0 . 5fK2...firma
header payload (datos) firma- El payload se puede leer (es Base64, no está cifrado): nunca pongas contraseñas ahí.
- La firma garantiza que nadie lo modificó: sólo el servidor con
JWT_SECRETpuede crear tokens válidos.
Analogía: es como el gafete de un evento. El guardia no llama a la oficina cada vez; sólo verifica que el gafete tenga el sello auténtico y no esté vencido.
¿Dónde guardar el token en el navegador?
| Lugar | Riesgo |
|---|---|
localStorage | Cualquier script (incluido uno inyectado por XSS) puede leerlo |
| Cookie httpOnly | ✅ JavaScript no puede leerla; el navegador la envía sola |
Configuración segura de cookie: httpOnly, secure (sólo HTTPS), sameSite (protege contra CSRF) y maxAge.
Instalación
bash
npm i express bcrypt jsonwebtoken cookie-parser zodINFO
Si bcrypt da problemas al compilar en tu sistema, usa bcryptjs (JavaScript puro, misma API): npm i bcryptjs e import bcrypt from 'bcryptjs'.
Ejemplo completo
Usamos un "repositorio" en memoria para centrarnos en la autenticación; en el proyecto final lo conectamos a Prisma.
js
import bcrypt from 'bcrypt'
const RONDAS = 12 // costo: cada +1 duplica el tiempo (~250 ms con 12)
export function hashPassword(plano) {
return bcrypt.hash(plano, RONDAS)
}
export function verificarPassword(plano, hash) {
return bcrypt.compare(plano, hash)
}js
import jwt from 'jsonwebtoken'
const SECRET = process.env.JWT_SECRET
if (!SECRET) throw new Error('Falta JWT_SECRET')
export function firmarToken(usuario) {
// sub (subject) = id del usuario. Sólo datos NO sensibles.
return jwt.sign({ sub: String(usuario.id), rol: usuario.rol }, SECRET, {
expiresIn: '1h',
algorithm: 'HS256'
})
}
export function verificarToken(token) {
// Lanza error si la firma es inválida o expiró
return jwt.verify(token, SECRET, { algorithms: ['HS256'] })
}js
// Repositorio en memoria (sustitúyelo por tu base de datos)
const usuarios = []
let siguienteId = 1
export async function buscarPorEmail(email) {
return usuarios.find((u) => u.email === email) ?? null
}
export async function buscarPorId(id) {
return usuarios.find((u) => u.id === id) ?? null
}
export async function crear({ nombre, email, passwordHash, rol = 'cliente' }) {
const usuario = { id: siguienteId++, nombre, email, passwordHash, rol }
usuarios.push(usuario)
return usuario
}js
import { verificarToken } from '../utils/jwt.js'
export const COOKIE = 'token'
// Exige sesión válida
export function requiereAuth(req, res, next) {
// Acepta cookie (navegador) o header Authorization: Bearer (apps móviles)
const header = req.headers.authorization
const token = req.cookies?.[COOKIE] ?? (header?.startsWith('Bearer ') ? header.slice(7) : null)
if (!token) return res.status(401).json({ error: 'No autenticado' })
try {
const payload = verificarToken(token)
req.usuario = { id: Number(payload.sub), rol: payload.rol }
next()
} catch {
return res.status(401).json({ error: 'Sesión inválida o expirada' })
}
}
// Exige uno de los roles indicados (usar DESPUÉS de requiereAuth)
export const requiereRol = (...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 { z } from 'zod'
import * as repo from '../repos/usuarios.repo.js'
import { hashPassword, verificarPassword } from '../utils/password.js'
import { firmarToken } from '../utils/jwt.js'
import { COOKIE } from '../middlewares/auth.js'
const registroSchema = z.object({
nombre: z.string().trim().min(2).max(60),
email: z.email().transform((e) => e.toLowerCase()),
password: z
.string()
.min(8, 'Mínimo 8 caracteres')
.max(72, 'Máximo 72 caracteres') // bcrypt sólo usa los primeros 72 bytes
.regex(/[A-Z]/, 'Debe incluir una mayúscula')
.regex(/[0-9]/, 'Debe incluir un número')
})
const loginSchema = z.object({
email: z.email().transform((e) => e.toLowerCase()),
password: z.string().min(1)
})
const opcionesCookie = {
httpOnly: true, // JS del navegador no puede leerla
secure: process.env.NODE_ENV === 'production', // sólo HTTPS en producción
sameSite: 'lax', // protege contra CSRF
maxAge: 60 * 60 * 1000, // 1 hora (igual que el JWT)
path: '/'
}
// Nunca devolvemos el hash al cliente
const publico = ({ passwordHash, ...resto }) => resto
export async function registro(req, res) {
const r = registroSchema.safeParse(req.body)
if (!r.success) {
return res.status(400).json({ error: 'Datos inválidos', detalles: r.error.issues.map((i) => i.message) })
}
const { nombre, email, password } = r.data
if (await repo.buscarPorEmail(email)) {
return res.status(409).json({ error: 'El email ya está registrado' })
}
const usuario = await repo.crear({ nombre, email, passwordHash: await hashPassword(password) })
res.cookie(COOKIE, firmarToken(usuario), opcionesCookie)
res.status(201).json({ usuario: publico(usuario) })
}
export async function login(req, res) {
const r = loginSchema.safeParse(req.body)
if (!r.success) return res.status(400).json({ error: 'Email y contraseña son obligatorios' })
const usuario = await repo.buscarPorEmail(r.data.email)
const ok = usuario && (await verificarPassword(r.data.password, usuario.passwordHash))
// Mismo mensaje en ambos casos: no revelamos si el email existe
if (!ok) return res.status(401).json({ error: 'Credenciales incorrectas' })
res.cookie(COOKIE, firmarToken(usuario), opcionesCookie)
res.json({ usuario: publico(usuario) })
}
export async function perfil(req, res) {
const usuario = await repo.buscarPorId(req.usuario.id)
if (!usuario) return res.status(404).json({ error: 'Usuario no encontrado' })
res.json({ usuario: publico(usuario) })
}
export function logout(req, res) {
res.clearCookie(COOKIE, { ...opcionesCookie, maxAge: undefined })
res.sendStatus(204)
}js
import { Router } from 'express'
import * as ctrl from '../controllers/auth.controller.js'
import { requiereAuth } from '../middlewares/auth.js'
const router = Router()
router.post('/registro', ctrl.registro)
router.post('/login', ctrl.login)
router.post('/logout', ctrl.logout)
router.get('/perfil', requiereAuth, ctrl.perfil)
export default routerjs
import express from 'express'
import cookieParser from 'cookie-parser'
import authRouter from './routes/auth.routes.js'
import { requiereAuth, requiereRol } from './middlewares/auth.js'
const app = express()
app.use(express.json())
app.use(cookieParser()) // llena req.cookies
app.use('/api/auth', authRouter)
// Ruta sólo para administradores
app.get('/api/admin/reportes', requiereAuth, requiereRol('admin'), (req, res) => {
res.json({ ventasHoy: 15230 })
})
export default appjs
import app from './app.js'
app.listen(3000, () => console.log('http://localhost:3000'))bash
# Ejecuta con tu .env que contiene JWT_SECRET
node --env-file=.env src/index.jsProbar con curl (guardando cookies)
bash
# Registro: -c guarda las cookies en un archivo
curl -c cookies.txt -X POST http://localhost:3000/api/auth/registro \
-H "Content-Type: application/json" \
-d '{"nombre":"Ana","email":"ana@correo.com","password":"Secreta123"}'
# Ruta protegida: -b envía las cookies guardadas
curl -b cookies.txt http://localhost:3000/api/auth/perfil
# Sin cookie → 401
curl http://localhost:3000/api/auth/perfilConsumir desde el frontend
Si tu frontend está en otro origen (por ejemplo Vite en localhost:5173), hay que permitir credenciales en ambos lados:
js
// Frontend: incluye la cookie en cada petición
const res = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password })
})js
// Backend: CORS con credenciales (lo verás a fondo en la lección 16)
import cors from 'cors'
app.use(cors({ origin: 'http://localhost:5173', credentials: true }))Nivel profesional: access + refresh token
Un token de larga duración es peligroso si se roba. El patrón profesional:
- Access token corto (10–15 min) para cada petición.
- Refresh token largo (7–30 días) en otra cookie httpOnly con
path: '/api/auth/refresh', guardado (hasheado) en la base de datos para poder revocarlo. - Cuando el access expira, el frontend llama a
/api/auth/refresh, que valida el refresh, lo rota (emite uno nuevo e invalida el viejo) y entrega otro access.
js
// Esquema simplificado del endpoint de refresh
router.post('/refresh', async (req, res) => {
const token = req.cookies.refresh
if (!token) return res.status(401).json({ error: 'Sin refresh token' })
const registro = await buscarRefreshValido(token) // en BD, no revocado ni vencido
if (!registro) return res.status(401).json({ error: 'Refresh inválido' })
await revocar(registro.id) // rotación
const usuario = await repo.buscarPorId(registro.usuarioId)
res.cookie('token', firmarToken(usuario), { ...opcionesCookie, maxAge: 15 * 60 * 1000 })
res.cookie('refresh', await crearRefresh(usuario.id), { ...opcionesCookie, path: '/api/auth/refresh', maxAge: 7 * 864e5 })
res.sendStatus(204)
})Buena práctica
- Usa mensajes genéricos ("Credenciales incorrectas") para no revelar qué emails existen.
- Limita los intentos de login con rate limiting (lección 16).
- Si prefieres no implementarlo todo, existen soluciones probadas: Auth.js, Better Auth, Clerk, Supabase Auth.
Errores graves
- Guardar contraseñas en texto plano o con MD5/SHA1 (son rápidos de romper).
- Usar un
JWT_SECRETcorto o el mismo en todos los entornos. - Aceptar el algoritmo
noneo no fijaralgorithmsal verificar. - Poner datos sensibles en el payload del JWT: cualquiera puede leerlo en jwt.io.
Ejercicios
- Agrega
PATCH /api/auth/password(protegida) que recibaactualynueva, verifique la actual y guarde el nuevo hash. - Crea una ruta
DELETE /api/usuarios/:idque sólo puedan usar admins o el propio usuario.
Ver solución
js
// 1. auth.controller.js
const cambioSchema = z.object({
actual: z.string().min(1),
nueva: z.string().min(8).max(72).regex(/[A-Z]/).regex(/[0-9]/)
})
export async function cambiarPassword(req, res) {
const r = cambioSchema.safeParse(req.body)
if (!r.success) return res.status(400).json({ error: 'Datos inválidos' })
const usuario = await repo.buscarPorId(req.usuario.id)
const ok = await verificarPassword(r.data.actual, usuario.passwordHash)
if (!ok) return res.status(401).json({ error: 'La contraseña actual no es correcta' })
usuario.passwordHash = await hashPassword(r.data.nueva) // en BD: un UPDATE
res.sendStatus(204)
}
// auth.routes.js
router.patch('/password', requiereAuth, ctrl.cambiarPassword)js
// 2. middleware de "admin o dueño"
export function adminODueño(req, res, next) {
const esDueño = req.usuario.id === Number(req.params.id)
if (req.usuario.rol !== 'admin' && !esDueño) {
return res.status(403).json({ error: 'No tienes permiso' })
}
next()
}
app.delete('/api/usuarios/:id', requiereAuth, adminODueño, async (req, res) => {
// await repo.eliminar(Number(req.params.id))
res.sendStatus(204)
})Resumen
- Contraseñas:
bcrypt.hashal registrar ybcrypt.compareal iniciar sesión. - JWT:
jwt.signcon datos mínimos y expiración;jwt.verifyfijando el algoritmo. - Guarda el token en una cookie httpOnly, secure, sameSite; léela con
cookie-parser. - Middlewares
requiereAuth(401) yrequiereRol(403) protegen las rutas.