Skip to content

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_SECRET puede 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? ​

LugarRiesgo
localStorageCualquier 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 zod

INFO

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 router
js
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 app
js
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.js

Probar 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/perfil

Consumir 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_SECRET corto o el mismo en todos los entornos.
  • Aceptar el algoritmo none o no fijar algorithms al verificar.
  • Poner datos sensibles en el payload del JWT: cualquiera puede leerlo en jwt.io.

Ejercicios ​

  1. Agrega PATCH /api/auth/password (protegida) que reciba actual y nueva, verifique la actual y guarde el nuevo hash.
  2. Crea una ruta DELETE /api/usuarios/:id que 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.hash al registrar y bcrypt.compare al iniciar sesión.
  • JWT: jwt.sign con datos mínimos y expiración; jwt.verify fijando el algoritmo.
  • Guarda el token en una cookie httpOnly, secure, sameSite; léela con cookie-parser.
  • Middlewares requiereAuth (401) y requiereRol (403) protegen las rutas.

Academia Web — aprende, copia y construye.