Módulo 17: Autenticación y autorización

Objetivos del módulo

  • Distinguir autenticación (quién eres) de autorización (qué puedes hacer), y responder con el código HTTP correcto en cada caso (401 o 403)
  • Guardar contraseñas como se debe: cifradas con un algoritmo lento y con sal, nunca en claro
  • Construir una sesión propia sin estado con un JWT firmado con jose, guardado en una cookie httpOnly, y saber qué hace cada atributo de la cookie
  • Escribir el inicio y el cierre de sesión con Server Actions que funcionan también sin JavaScript
  • Centralizar la comprobación de la sesión en una capa de acceso a datos (DAL) con cache de React y server-only
  • Proteger páginas, Server Actions y Route Handlers, cada uno por su cuenta, con roles ADMIN y USER
  • Usar proxy.ts como primera barrera optimista, y entender por qué nunca debe ser la única
  • Hacer que la autenticación encaje con Cache Components: qué puede leer la sesión, qué se puede guardar en caché y con qué clave
  • Atacar tu propia aplicación con curl (tokens manipulados, firmas falsas, cookies robadas, redirecciones abiertas, CSRF) y comprobar que resiste
  • Montar lo mismo con Better Auth 1.7 y su adaptador de Drizzle, y saber cuándo conviene una biblioteca y cuándo hacerlo tú
  • Saber por qué este curso no usa Auth.js v5, y qué cambia si sigues en Next.js 15

💡 Consejo: Todo el código de este módulo se ha compilado con next build y ejecutado con next start en Next.js 16.4.3, con jose 6.2.15, better-auth 1.7.15, drizzle-orm 0.45.4 y @libsql/client 0.18.0 sobre Node.js 24. Las salidas que ves (cabeceras Set-Cookie, respuestas de curl, registros del servidor, errores del build) son las reales. Las alternativas para Next.js 15 se han compilado y ejecutado en Next.js 15.5.28. Usa siempre el último parche de Next.js 16.4.

📘 Concepto: Este módulo parte de la base de datos del módulo 16: el esquema EMPRESA, recursos/empresa-db.zip y la capa lib/db. Le añade una tabla usuarios (sección 3) y, para Better Auth, otras cuatro (sección 13). Si no has hecho el módulo 16, descomprime el recurso y ejecuta npm run db:preparar antes de empezar.


1. Autenticación, autorización y sesión

Tres palabras que se confunden mucho y que conviene separar desde el principio:

Palabra Pregunta que responde Ejemplo en EMPRESA Si falla
Autenticación (authentication, “authn”) ¿Quién eres? Ana demuestra con su email y su contraseña que es Ana 401 Unauthorized: “no sé quién eres, identifícate”
Autorización (authorization, “authz”) ¿Qué puedes hacer? Ana es ADMIN y puede ver las nóminas; Luis es USER y solo ve la suya 403 Forbidden: “sé quién eres, y no puedes”
Sesión ¿Cómo recuerdo quién eres en la siguiente petición? Una cookie que el navegador envía con cada petición Hay que volver a identificarse

📘 Concepto: El nombre del código 401 es engañoso: dice Unauthorized, pero significa “no autenticado”. El 403 es el de “no autorizado”. En este módulo los usaremos con ese sentido: un Route Handler sin sesión responde 401, y con sesión pero sin permiso, 403.

HTTP no tiene memoria: cada petición llega sola, sin saber nada de las anteriores. La sesión es el truco para que el servidor reconozca al usuario sin pedirle la contraseña cada vez. Tras comprobar la contraseña una vez, el servidor le da al navegador una credencial temporal (un token) que este devuelve en cada petición dentro de una cookie. Hay dos formas de diseñar esa credencial:

  • Sesión sin estado (stateless): la cookie contiene los datos de la sesión (quién es y hasta cuándo vale), firmados por el servidor para que nadie pueda cambiarlos. El servidor no guarda nada: para comprobar la sesión le basta con verificar la firma. Es lo que construirás con jose (secciones 4 a 12).
  • Sesión con estado (stateful, o “en base de datos”): la cookie solo contiene un identificador aleatorio, una llave. Los datos de la sesión están en una tabla del servidor. Comprobar la sesión es buscar esa llave en la tabla. Es lo que hace Better Auth (sección 13).

Ninguna es mejor en todo. Las compararás con ataques reales en la sección 12.

1.1. Hacerlo tú, usar una biblioteca o contratar un servicio

En una aplicación Next tienes tres caminos para la autenticación:

Camino Ejemplos Ventajas Inconvenientes
Hacerlo tú jose para firmar, node:crypto para las contraseñas Entiendes cada línea; ninguna dependencia grande; control total Todo lo que olvides es un agujero: límite de intentos, recuperación de contraseña, verificación de email…
Biblioteca en tu servidor Better Auth, Auth.js (NextAuth) Resuelve lo de siempre (contraseñas, sesiones, OAuth con Google o GitHub, 2FA) con tus propias tablas Otra dependencia que actualizar y entender
Servicio externo Clerk, Auth0, Supabase Auth, WorkOS Casi nada que programar ni que mantener Tus usuarios viven en el servidor de otro; coste por usuario; dependes del proveedor

Este módulo hace primero el camino largo: sesión propia con jose. No porque sea lo que debes usar siempre, sino porque es la única forma de entender qué hacen por dentro las bibliotecas, y porque la guía oficial de Next (node_modules/next/dist/docs/01-app/02-guides/authentication.md) la usa como ejemplo. Después, en la sección 13, harás lo mismo con Better Auth en una fracción del código.

1.2. Por qué no Auth.js v5

Auth.js (antes NextAuth.js) ha sido durante años la biblioteca de autenticación más usada con Next. Su versión 5, la que está pensada para el App Router, sigue en beta. Lo comprobamos en npm el 2 de noviembre de 2026:

npm view next-auth dist-tags --json
{
  "canary": "3.24.0-canary.0",
  "next": "4.0.0-next.26",
  "next-auth-3": "3.29.10",
  "experimental": "0.0.0-manual.2824fa11",
  "latest": "4.24.15",
  "beta": "5.0.0-beta.32"
}

La versión estable (latest) es la 4, diseñada para el Pages Router. La 5 se instala con npm install next-auth@beta, y va por la beta 32. Además, la propia documentación de Auth.js anuncia que “The Auth.js project is now part of Better Auth” y ofrece una guía para migrar a Better Auth. Por eso este curso no la usa: no tiene sentido enseñar una beta cuyos autores recomiendan migrar a otra biblioteca. Si heredas un proyecto con Auth.js v5, las ideas de este módulo (DAL, comprobar en cada acción, proxy optimista) se aplican igual.

1.3. Lo que vas a construir

La zona de gestión de EMPRESA, con tres usuarios de prueba:

Usuario Contraseña Rol Empleado
ana.garcia@empresa.es clave-ana-2026 ADMIN 100, Ana García (directora general)
luis.martinez@empresa.es clave-luis-2026 USER 101, Luis Martínez (VENTAS)
marta.sanchez@empresa.es clave-marta-2026 USER 102, Marta Sánchez (INFORMÁTICA)

Y estas rutas:

Ruta Quién puede entrar Qué enseña
/ Todos Página pública: no lee la sesión, sigue siendo estática
/entrar Todos Formulario de inicio de sesión (Server Action)
/gestion Cualquier usuario con sesión Saludo con el nombre y el rol
/gestion/mi-ficha Cualquier usuario con sesión Los datos del propio empleado, en caché por empleado
/gestion/nominas Solo ADMIN Tabla de salarios con un editor (Server Action protegida)
/api/empleados/[empId]/nomina ADMIN, o el propio empleado Route Handler que responde 401, 403, 404 o 200
/ba/entrar, /ba/panel   Lo mismo con Better Auth

Estructura de ficheros (sobre el proyecto del módulo 16):

empresa/
├── .env.local                     SESSION_SECRET, BETTER_AUTH_SECRET, BETTER_AUTH_URL
├── proxy.ts                       primera barrera (optimista)
├── auth.config.ts                 solo para la CLI de Better Auth
├── db/
│   ├── schema.ts                  + tabla usuarios
│   └── auth-schema.ts             tablas de Better Auth (generadas)
├── drizzle/
│   ├── 0001_usuarios.sql
│   └── 0002_better_auth.sql
├── lib/
│   ├── claves.ts                  cifrar y comparar contraseñas (scrypt)
│   ├── sesion.ts                  firmar, verificar, crear y borrar la cookie (jose)
│   ├── auth.ts                    instancia de Better Auth
│   ├── auth-opciones.ts           opciones compartidas de Better Auth
│   ├── auth-cliente.ts            cliente de Better Auth para el navegador
│   ├── db/index.ts                cliente de Drizzle (módulo 16)
│   └── dal/
│       ├── auth.ts                obtenerUsuario, verificarSesion, exigirRol...
│       ├── auth-ba.ts             lo mismo con Better Auth
│       └── empleados.ts           nóminas y fichas (autoriza y lee de la caché)
├── scripts/
│   ├── crear-usuarios.ts
│   └── crear-usuarios-ba.ts
└── app/
    ├── forbidden.tsx              página 403
    ├── entrar/                    page.tsx, FormularioEntrar.tsx, acciones.ts
    ├── gestion/                   layout.tsx, page.tsx, acciones.ts, mi-ficha/, nominas/
    ├── api/empleados/[empId]/nomina/route.ts
    ├── api/auth/[...all]/route.ts Better Auth
    └── ba/                        entrar/, panel/

1.4. Las tres capas de comprobación

La idea más importante del módulo cabe en un dibujo:

Diagrama de tres columnas: el proxy hace una comprobación optimista de la cookie sin base de datos; páginas, Server Actions y Route Handlers comprueban cada uno la sesión llamando a la DAL; la DAL verifica la firma, busca al usuario en la base de datos y toma de allí su rol

Figura 1. La sesión se comprueba en tres sitios. El proxy filtra pronto y barato, pero la decisión de verdad la toma la DAL, a la que llama cada página, cada acción y cada handler.

  1. El proxy (proxy.ts) mira la cookie antes de que se ejecute nada. Si no hay sesión válida, redirige a /entrar. Es rápido, pero optimista: no consulta la base de datos, y solo actúa en las rutas que le digas.
  2. Cada punto de entrada (página, Server Action, Route Handler) pide a la DAL el usuario actual y decide.
  3. La DAL (Data Access Layer) verifica la firma, busca al usuario en la base de datos y devuelve un objeto pequeño con lo justo. Es la única que sabe cómo funciona la sesión.

⚠️ Importante: Si te quedas con una sola idea de este módulo, que sea esta: la autorización se comprueba lo más cerca posible de los datos, en cada punto de entrada. Un layout que comprueba la sesión no protege a sus páginas, un proxy no protege a las Server Actions, y ocultar un botón no impide que alguien llame a la acción con curl. Lo demostrarás en la sección 12.


2. Preparar el proyecto

2.1. Paquetes

Sobre el proyecto del módulo 16 (que ya tiene drizzle-orm, @libsql/client, server-only, drizzle-kit y tsx), instala:

npm install jose zod better-auth
Paquete Para qué
jose Firmar y verificar JWT. Funciona en cualquier entorno JavaScript (Node, navegador, edge), sin dependencias
zod Validar los formularios (módulo 11)
better-auth La biblioteca de la sección 13. Incluye el adaptador de Drizzle (better-auth/adapters/drizzle)

Para cifrar contraseñas no instalas nada: usarás scrypt, que viene con Node en node:crypto (sección 3.2).

2.2. Secretos en .env.local

La firma de la sesión necesita una clave secreta. Genera una aleatoria de 32 bytes:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

(o openssl rand -base64 32, como propone la guía de Next). Ponla en .env.local, junto con las de Better Auth:

# .env.local  (nunca se sube al repositorio)
SESSION_SECRET=OoVbWqIxDN9jIGz...44 caracteres en base64...
BETTER_AUTH_SECRET=RffIwH4/ufi...otra clave distinta...
BETTER_AUTH_URL=http://localhost:3001
DATABASE_URL=file:empresa.db
  • Las variables no llevan el prefijo NEXT_PUBLIC_, así que nunca llegan al navegador (módulo 13).
  • .env.local ya está en el .gitignore que genera create-next-app. Comprueba que sigue ahí: un secreto subido a GitHub hay que darlo por perdido.
  • Si alguien obtiene SESSION_SECRET, puede fabricar sesiones de cualquier usuario. Si la cambias, todas las sesiones abiertas dejan de valer (es la forma de “echar a todo el mundo”).
  • BETTER_AUTH_URL es la URL pública de tu aplicación. En los ejemplos del curso Next escucha en el puerto 3001 (el 3000 es de la API EMPRESA); en el laboratorio de este módulo se usó http://127.0.0.1:5517.

⚠️ Importante: No reutilices el mismo secreto para dos cosas. Si la sesión propia y Better Auth compartieran clave, un fallo en una comprometería la otra.


3. Usuarios y contraseñas

3.1. La tabla usuarios

Añade al final de db/schema.ts (el del módulo 16):

// db/schema.ts (añadido en el módulo 17)

export const ROLES = ['ADMIN', 'USER'] as const
export type Rol = (typeof ROLES)[number]

export const usuarios = sqliteTable(
  'usuarios',
  {
    usuarioId: integer('usuario_id').primaryKey(),
    email: text('email').notNull().unique(),
    nombre: text('nombre').notNull(),
    hashClave: text('hash_clave').notNull(), // nunca la contraseña en claro
    rol: text('rol', { enum: ROLES }).notNull().default('USER'),
    empId: integer('emp_id').references(() => empleados.empId, { onDelete: 'set null' }),
    versionSesion: integer('version_sesion').notNull().default(1),
    creadoEn: integer('creado_en', { mode: 'timestamp' })
      .notNull()
      .default(sql`(unixepoch())`),
  },
  (t) => [check('usuarios_rol_valido', sql`${t.rol} in ('ADMIN', 'USER')`)],
)

export const usuariosRelaciones = relations(usuarios, ({ one }) => ({
  empleado: one(empleados, { fields: [usuarios.empId], references: [empleados.empId] }),
}))

export type Usuario = typeof usuarios.$inferSelect
export type NuevoUsuario = typeof usuarios.$inferInsert

Las decisiones de diseño:

  • usuarios es una tabla aparte de empleados. No todo usuario es un empleado (un gestor externo, una cuenta de servicio) ni todo empleado tiene acceso a la aplicación. empId enlaza los dos cuando corresponde, y admite nulos.
  • hashClave, no clave. El nombre recuerda a quien lea el esquema que ahí nunca hay una contraseña.
  • rol con enum y CHECK. El enum de Drizzle solo actúa en TypeScript; el CHECK hace que la propia base de datos rechace un rol inventado, aunque alguien escriba SQL a mano.
  • versionSesion sirve para cerrar todas las sesiones de un usuario a la vez. Parece un detalle; en la sección 12.4 verás que sin él una cookie robada sigue valiendo después de cerrar sesión.

Genera y aplica la migración:

npx drizzle-kit generate --name usuarios
npx drizzle-kit migrate
-- drizzle/0001_usuarios.sql (generado)
CREATE TABLE `usuarios` (
	`usuario_id` integer PRIMARY KEY NOT NULL,
	`email` text NOT NULL,
	`nombre` text NOT NULL,
	`hash_clave` text NOT NULL,
	`rol` text DEFAULT 'USER' NOT NULL,
	`emp_id` integer,
	`version_sesion` integer DEFAULT 1 NOT NULL,
	`creado_en` integer DEFAULT (unixepoch()) NOT NULL,
	FOREIGN KEY (`emp_id`) REFERENCES `empleados`(`emp_id`) ON UPDATE no action ON DELETE set null,
	CONSTRAINT "usuarios_rol_valido" CHECK("usuarios"."rol" in ('ADMIN', 'USER'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `usuarios_email_unique` ON `usuarios` (`email`);

3.2. Cómo se guarda una contraseña

Una contraseña nunca se guarda tal cual, ni cifrada con una clave que se pueda descifrar. Se guarda su hash: el resultado de una función de un solo sentido. Para comprobar una contraseña, se calcula el hash de lo que escribe el usuario y se compara con el guardado. Si alguien roba la base de datos, no obtiene las contraseñas.

Pero no vale cualquier función de hash:

Opción ¿Sirve? Por qué
Guardarla en claro No Quien lea la tabla (un volcado, una copia de seguridad, un empleado curioso) tiene todas las contraseñas
MD5, SHA-1, SHA-256 sin más No Son rápidas a propósito: una tarjeta gráfica prueba miles de millones por segundo. Y sin sal, dos usuarios con 123456 tienen el mismo hash, y existen tablas precalculadas
SHA-256 con sal No La sal evita las tablas precalculadas, pero sigue siendo rápida de atacar por fuerza bruta
scrypt, bcrypt, Argon2 Sí Son lentas y costosas en memoria a propósito, con un coste configurable, y llevan sal

La sal (salt) es un valor aleatorio distinto para cada usuario que se mezcla con la contraseña antes de calcular el hash. Así, dos usuarios con la misma contraseña tienen hashes distintos, y un atacante tiene que atacar cada hash por separado.

De las tres funciones buenas, scrypt tiene una ventaja práctica: viene en Node (node:crypto), así que no hay que instalar nada ni compilar binarios nativos. Better Auth usa también scrypt por defecto (lo verás en la sección 13). Argon2 es la recomendación más moderna, pero necesita el paquete nativo argon2.

// lib/claves.ts
// Cifrado de contraseñas con scrypt, incluido en Node (node:crypto).
// Formato guardado: scrypt$N$r$p$sal$hash (todo en base64url salvo los números)
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'

const N = 16384 // coste de CPU y memoria (2^14)
const r = 8
const p = 1
const LONGITUD = 64

function derivar(clave: string, sal: Buffer, opciones: ScryptOptions): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    scrypt(clave.normalize('NFKC'), sal, LONGITUD, opciones, (error, clave) =>
      error ? reject(error) : resolve(clave),
    )
  })
}

export async function cifrarClave(clave: string): Promise<string> {
  const sal = randomBytes(16)
  const hash = await derivar(clave, sal, { N, r, p })
  return ['scrypt', N, r, p, sal.toString('base64url'), hash.toString('base64url')].join('$')
}

export async function compararClave(clave: string, guardado: string): Promise<boolean> {
  const [algoritmo, n, rr, pp, salTexto, hashTexto] = guardado.split('$')
  if (algoritmo !== 'scrypt' || !salTexto || !hashTexto) return false
  const esperado = Buffer.from(hashTexto, 'base64url')
  const calculado = await derivar(clave, Buffer.from(salTexto, 'base64url'), {
    N: Number(n),
    r: Number(rr),
    p: Number(pp),
  })
  // timingSafeEqual tarda lo mismo acierte o falle: no da pistas por tiempo
  return calculado.length === esperado.length && timingSafeEqual(calculado, esperado)
}

Cómo funciona:

  • Se guarda todo lo necesario para repetir el cálculo: algoritmo, parámetros, sal y hash, separados por $. Si mañana subes el coste N, los hashes antiguos se siguen pudiendo comprobar con los parámetros con que se crearon.
  • scrypt con callback, envuelto en una promesa. Existe scryptSync, pero bloquearía el hilo del servidor mientras calcula: con varios inicios de sesión a la vez, todas las demás peticiones esperarían. La versión asíncrona se ejecuta en el pool de hilos de libuv.
  • normalize('NFKC'): la “ñ” puede escribirse como un carácter o como “n” más una tilde combinada. Sin normalizar, la misma contraseña tecleada en dos teclados daría hashes distintos.
  • timingSafeEqual compara los dos buffers en tiempo constante. Una comparación normal (===) se detiene en el primer byte distinto, y midiendo tiempos con mucha paciencia se podría ir adivinando el hash.

3.3. Crear los usuarios de prueba

// scripts/crear-usuarios.ts
// Crea (o vuelve a crear) los usuarios de prueba con sus contraseñas cifradas.
//   npm run db:usuarios
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
import * as schema from '../db/schema'
import { cifrarClave } from '../lib/claves'

const url = process.env.DATABASE_URL ?? 'file:empresa.db'
const cliente = createClient({ url })
const db = drizzle(cliente, { schema })

const USUARIOS = [
  { email: 'ana.garcia@empresa.es', nombre: 'Ana García', clave: 'clave-ana-2026', rol: 'ADMIN', empId: 100 },
  { email: 'luis.martinez@empresa.es', nombre: 'Luis Martínez', clave: 'clave-luis-2026', rol: 'USER', empId: 101 },
  { email: 'marta.sanchez@empresa.es', nombre: 'Marta Sánchez', clave: 'clave-marta-2026', rol: 'USER', empId: 102 },
] as const

async function crear() {
  const filas = await Promise.all(
    USUARIOS.map(async ({ clave, ...resto }) => ({ ...resto, hashClave: await cifrarClave(clave) })),
  )
  await db.transaction(async (tx) => {
    await tx.delete(schema.usuarios)
    await tx.insert(schema.usuarios).values(filas)
  })
  const creados = await db
    .select({ email: schema.usuarios.email, rol: schema.usuarios.rol, hash: schema.usuarios.hashClave })
    .from(schema.usuarios)
  for (const u of creados) console.log(u.rol.padEnd(5), u.email.padEnd(26), u.hash.slice(0, 40) + '...')
}

crear()
  .catch((error) => {
    console.error(error)
    process.exitCode = 1
  })
  .finally(() => cliente.close())

Añade el script a package.json ("db:usuarios": "tsx scripts/crear-usuarios.ts") y ejecútalo:

npm run db:usuarios
ADMIN ana.garcia@empresa.es      scrypt$16384$8$1$0OAaEfLW1yDkq3_04Mah9A$...
USER  luis.martinez@empresa.es   scrypt$16384$8$1$g588Mmc4rvbuOEfg8iXcpQ$...
USER  marta.sanchez@empresa.es   scrypt$16384$8$1$KqcqsiiJaVEdHS0O0gqGbw$...

Cada usuario tiene una sal distinta (la parte tras $1$), así que aunque dos tuvieran la misma contraseña, sus hashes no se parecerían.

⚠️ Importante: El script de carga del módulo 16 (npm run db:cargar) vacía y rellena empleados. Como usuarios.emp_id tiene ON DELETE set null, después de recargar los empleados los usuarios quedan sin empleado asociado. Ejecuta db:usuarios después de db:cargar.


4. La sesión con jose

4.1. Qué es un JWT

Un JWT (JSON Web Token, se pronuncia “jot”) es un texto con tres partes separadas por puntos: cabecera, contenido y firma. Este es un token real emitido por la aplicación al iniciar sesión Ana:

eyJhbGciOiJIUzI1NiJ9.eyJ1c3VhcmlvSWQiOjEsInJvbCI6IkFETUlOIiwidmVyc2lvbiI6MSwic3ViIjoiMSIsImlhdCI6MTc5MzYyNzc2OSwiZXhwIjoxNzkzNjU2NTY5fQ.DK1Y2aOcRPtMqHbJ9X32exue4DcW-kpg2qo6oTtmdoQ

Las dos primeras partes son JSON codificado en base64url, no cifrado. Cualquiera puede leerlo:

echo eyJ1c3VhcmlvSWQiOjEsInJvbCI6IkFETUlOIiwidmVyc2lvbiI6MSwic3ViIjoiMSIsImlhdCI6MTc5MzYyNzc2OSwiZXhwIjoxNzkzNjU2NTY5fQ | base64 -d
{"usuarioId":1,"rol":"ADMIN","version":1,"sub":"1","iat":1793627769,"exp":1793656569}

La tercera parte es la firma: un HMAC-SHA256 de las dos primeras calculado con SESSION_SECRET. Si alguien cambia una sola letra del contenido (por ejemplo, "rol":"USER" por "rol":"ADMIN"), la firma deja de coincidir y el servidor rechaza el token. Lo comprobarás en la sección 12.

Diagrama de un JWT en tres cajas: cabecera con alg HS256, contenido legible con usuarioId, rol, version, sub, iat y exp, y firma HMAC-SHA256 con el secreto; debajo, la cabecera Set-Cookie con HttpOnly, Secure, SameSite=lax y Max-Age

Figura 2. Un JWT firmado no oculta su contenido: solo garantiza que nadie lo ha cambiado. Por eso dentro va lo mínimo, y nunca nada secreto.

Los campos sub (subject, el usuario), iat (issued at, cuándo se emitió) y exp (expiration, cuándo caduca) son estándar; jose los rellena con setSubject, setIssuedAt y setExpirationTime, y jwtVerify rechaza solo un token caducado.

📘 Concepto: Firmar no es cifrar. Un JWT firmado (JWS) garantiza integridad: nadie lo ha modificado. No garantiza confidencialidad: todos pueden leerlo. Existe el JWT cifrado (JWE, EncryptJWT en jose), pero para una sesión no hace falta si no metes nada privado dentro, que es lo correcto.

4.2. lib/sesion.ts

// lib/sesion.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
import type { Rol } from '@/db/schema'

export const COOKIE_SESION = 'sesion'
const DURACION_SEGUNDOS = 60 * 60 * 8 // una jornada: 8 horas

// Lo mínimo para reconocer al usuario: nada de email, nombre ni datos personales
export type DatosSesion = {
  usuarioId: number
  rol: Rol
  version: number // versión de sesión del usuario (para revocar)
}

const secreto = process.env.SESSION_SECRET
if (!secreto || secreto.length < 32) {
  throw new Error('Falta SESSION_SECRET (32 caracteres o más) en .env.local')
}
const clave = new TextEncoder().encode(secreto)

export async function cifrarSesion(datos: DatosSesion, duracion = DURACION_SEGUNDOS) {
  return new SignJWT(datos)
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(String(datos.usuarioId))
    .setIssuedAt()
    .setExpirationTime(Math.floor(Date.now() / 1000) + duracion)
    .sign(clave)
}

export async function descifrarSesion(token: string | undefined): Promise<DatosSesion | null> {
  if (!token) return null
  try {
    const { payload } = await jwtVerify<DatosSesion>(token, clave, { algorithms: ['HS256'] })
    if (typeof payload.usuarioId !== 'number') return null
    if (payload.rol !== 'ADMIN' && payload.rol !== 'USER') return null
    return { usuarioId: payload.usuarioId, rol: payload.rol, version: payload.version }
  } catch (error) {
    // Los errores de jose traen un código: ERR_JWT_EXPIRED, ERR_JWS_SIGNATURE_VERIFICATION_FAILED...
    console.log('[sesion] token rechazado:', (error as { code?: string }).code)
    return null
  }
}

export async function crearSesion(datos: DatosSesion) {
  const token = await cifrarSesion(datos)
  const almacen = await cookies()
  almacen.set(COOKIE_SESION, token, {
    httpOnly: true, // JavaScript del navegador no puede leerla
    secure: process.env.NODE_ENV === 'production', // solo por HTTPS (localhost es la excepción)
    sameSite: 'lax', // no viaja en peticiones POST desde otros sitios
    path: '/',
    maxAge: DURACION_SEGUNDOS,
  })
}

export async function leerSesion(): Promise<DatosSesion | null> {
  const almacen = await cookies()
  return descifrarSesion(almacen.get(COOKIE_SESION)?.value)
}

export async function borrarSesion() {
  const almacen = await cookies()
  almacen.delete(COOKIE_SESION)
}

Línea a línea, lo que importa:

  • import 'server-only': este módulo maneja el secreto. Si un Client Component lo importara, el build fallaría (módulo 05).
  • El secreto se comprueba al cargar el módulo. Si falta SESSION_SECRET, la aplicación falla en el arranque con un mensaje claro, en vez de firmar con undefined y aceptar tokens firmados con una clave vacía.
  • algorithms: ['HS256'] en jwtVerify. Es la defensa contra el ataque clásico de cambiar la cabecera a "alg": "none" (token sin firma). jose ya rechaza none por defecto, pero fijar la lista es la buena práctica: el servidor decide el algoritmo, no el token.
  • descifrarSesion comprueba la forma del contenido. Que la firma sea válida significa que el token lo emitió tu servidor, pero no que tenga la forma que esperas (quizá lo emitió una versión antigua de tu código).
  • descifrarSesion no lanza errores: devuelve null. Un token malo no es una situación excepcional, es lo normal cuando alguien intenta algo raro, y el código que llama solo necesita saber “hay sesión o no”.
  • descifrarSesion no lee cookies. Recibe el token como texto. Así puede usarla también el proxy, que lee la cookie de la petición directamente (sección 10).
Atributo Valor Qué evita
httpOnly true Que JavaScript la lea con document.cookie. Si un atacante consigue inyectar un script en tu página (XSS), no puede robar la sesión
secure true en producción Que viaje por HTTP sin cifrar, donde cualquiera en la misma red wifi la leería
sameSite 'lax' Que el navegador la envíe en peticiones POST que vienen de otros sitios (la base del CSRF). Sí se envía al seguir un enlace normal desde otro sitio, para no perder la sesión
path '/' Que solo se envíe en una parte de la web
maxAge 8 horas Que la cookie viva para siempre en el navegador. Coincide con el exp del token

La cabecera real que envía el servidor tras iniciar sesión (con next start, es decir, en producción):

HTTP/1.1 303 See Other
Set-Cookie: sesion=eyJhbGciOiJIUzI1NiJ9.eyJ1c3VhcmlvSWQiOjEsInJvbCI6IkFETUlOIiwidmVyc2lvbiI6MSwic3ViIjoiMSIsImlhdCI6MTc5MzYyNzc2OSwiZXhwIjoxNzkzNjU2NTY5fQ.DK1Y2aOcRPtMqHbJ9X32exue4DcW-kpg2qo6oTtmdoQ; Path=/; Expires=Mon, 02 Nov 2026 21:56:09 GMT; Max-Age=28800; Secure; HttpOnly; SameSite=lax
Location: /gestion

Y lo que ve el navegador. Con un script de Playwright, tras iniciar sesión se evaluó document.cookie en la página y se pidió a Chromium la lista de cookies:

document.cookie desde JavaScript: ""
cookie vista por el navegador: [ 'sesion httpOnly=true secure=true sameSite=Lax' ]

La cookie existe y viaja en cada petición, pero el JavaScript de la página no la ve.

💡 Consejo: secure: true con next start en tu ordenador funciona porque los navegadores tratan localhost y 127.0.0.1 como orígenes seguros aunque no haya HTTPS. Detrás de un servidor con HTTP puro y otro nombre (una IP de tu red, por ejemplo), el navegador descartaría la cookie en silencio y no podrías iniciar sesión. En producción, siempre HTTPS (módulo 23).

4.4. Qué va dentro del token y qué no

El contenido del token viaja en cada petición y cualquiera puede leerlo. Reglas:

  • Sí: un identificador (usuarioId), el rol (como pista para el proxy) y la versión de sesión.
  • No: email, nombre, teléfono, salario, ni por supuesto la contraseña o su hash. La guía de Next lo dice expresamente: el contenido debe ser “the minimum, unique user data”.
  • El rol del token no es la fuente de verdad. Si un administrador le quita el rol ADMIN a alguien, su token seguirá diciendo ADMIN hasta que caduque. Por eso la DAL (sección 6) toma el rol de la base de datos, y el del token solo lo usa el proxy, que es optimista por definición.

5. Iniciar y cerrar sesión

5.1. La acción entrar

Diagrama de secuencia: el navegador envía email y clave a la acción entrar; la acción valida con Zod, lee el hash de la base de datos, lo compara con scrypt y timingSafeEqual, firma el JWT, crea la cookie y responde 303 con Set-Cookie; el navegador pide /gestion con la cookie

Figura 3. El inicio de sesión completo ocurre en una Server Action. La contraseña solo existe en memoria durante la comprobación.

// app/entrar/acciones.ts
'use server'

import { redirect } from 'next/navigation'
import { z } from 'zod'
import { comprobarCredenciales, obtenerUsuario, revocarSesiones } from '@/lib/dal/auth'
import { borrarSesion, crearSesion } from '@/lib/sesion'

export type EstadoEntrar = { error?: string; email?: string }

const esquemaEntrar = z.object({
  email: z.email('Escribe un email válido'),
  clave: z.string().min(1, 'Escribe la contraseña'),
  desde: z.string().optional(),
})

// Solo rutas de esta misma web: "/gestion" sí; "//malo.example" o "https://..." no
function destinoSeguro(desde: string | undefined) {
  if (desde && desde.startsWith('/') && !desde.startsWith('//') && !desde.startsWith('/\\')) {
    return desde
  }
  return '/gestion'
}

export async function entrar(_previo: EstadoEntrar, formData: FormData): Promise<EstadoEntrar> {
  const datos = esquemaEntrar.safeParse(Object.fromEntries(formData))
  if (!datos.success) {
    return { error: datos.error.issues[0].message, email: String(formData.get('email') ?? '') }
  }

  const usuario = await comprobarCredenciales(datos.data.email, datos.data.clave)
  if (!usuario) {
    // El mismo mensaje si falla el email o la contraseña: no damos pistas
    return { error: 'Email o contraseña incorrectos', email: datos.data.email }
  }

  await crearSesion(usuario)
  redirect(destinoSeguro(datos.data.desde)) // fuera de cualquier try/catch
}

export async function salir() {
  await borrarSesion()
  redirect('/entrar')
}

export async function salirDeTodo() {
  const usuario = await obtenerUsuario()
  if (usuario) await revocarSesiones(usuario.usuarioId)
  await borrarSesion()
  redirect('/entrar')
}

Cuatro detalles de seguridad en pocas líneas:

  1. Un único mensaje de error. Si la aplicación dijera “ese email no existe” y “contraseña incorrecta” por separado, un atacante podría averiguar qué emails tienen cuenta (enumeración de usuarios) y concentrar ahí sus intentos.
  2. Mismo tiempo de respuesta. De nada sirve el mensaje único si, cuando el email no existe, la respuesta llega en 2 ms y, cuando existe, en 80 ms (lo que tarda scrypt). comprobarCredenciales (sección 6.1) compara contra un hash ficticio cuando el email no existe, para que los dos casos tarden lo mismo. Medido con curl en el laboratorio, contraseña mala con un email que existe y con uno que no, tres veces cada uno:

    ana.garcia@empresa.es TIEMPO=0,185149
    nadie@empresa.es TIEMPO=0,196863
    ana.garcia@empresa.es TIEMPO=0,269922
    nadie@empresa.es TIEMPO=0,185584
    ana.garcia@empresa.es TIEMPO=0,162498
    nadie@empresa.es TIEMPO=0,145156
    

    Las diferencias son ruido: no hay un patrón que delate qué email existe.

  3. destinoSeguro. El proxy envía a /entrar?desde=/gestion/nominas para volver allí tras iniciar sesión. Si se redirigiera a cualquier valor de desde, un atacante podría enviar a alguien un enlace a tu-web/entrar?desde=https://web-falsa.example y, tras iniciar sesión en tu web de verdad, la víctima acabaría en una copia que le pide otra vez la contraseña (redirección abierta). Solo se aceptan rutas que empiezan por una sola /; //malo.example es una URL de otro sitio para el navegador, aunque empiece por /.
  4. redirect fuera de try. redirect funciona lanzando una excepción especial; dentro de un try/catch se la tragaría el catch (módulo 10).

5.2. El formulario

// app/entrar/FormularioEntrar.tsx
'use client'

import { useActionState } from 'react'
import { entrar, type EstadoEntrar } from './acciones'

export function FormularioEntrar({ desde }: { desde?: string }) {
  const [estado, accion, pendiente] = useActionState<EstadoEntrar, FormData>(entrar, {})

  return (
    <form action={accion} className="flex flex-col gap-3 bg-white p-6 rounded-lg shadow w-80">
      <label className="flex flex-col gap-1">
        Email
        <input
          name="email"
          type="email"
          required
          autoComplete="username"
          defaultValue={estado.email}
          className="border rounded px-2 py-1"
        />
      </label>
      <label className="flex flex-col gap-1">
        Contraseña
        <input
          name="clave"
          type="password"
          required
          autoComplete="current-password"
          className="border rounded px-2 py-1"
        />
      </label>
      {desde && <input type="hidden" name="desde" value={desde} />}
      {estado.error && (
        <p role="alert" className="text-red-700 text-sm">
          {estado.error}
        </p>
      )}
      <button disabled={pendiente} className="bg-indigo-700 text-white rounded py-2 disabled:opacity-50">
        {pendiente ? 'Comprobando...' : 'Entrar'}
      </button>
    </form>
  )
}
// app/entrar/page.tsx
import { Suspense } from 'react'
import { FormularioEntrar } from './FormularioEntrar'

export default function PaginaEntrar({ searchParams }: PageProps<'/entrar'>) {
  return (
    <main className="p-6 flex flex-col items-center gap-4">
      <h1 className="text-2xl font-bold">Iniciar sesión</h1>
      <Suspense fallback={<FormularioEntrar />}>
        <FormularioConDestino searchParams={searchParams} />
      </Suspense>
    </main>
  )
}

// searchParams es un dato de la petición: se lee dentro de <Suspense>
async function FormularioConDestino({ searchParams }: Pick<PageProps<'/entrar'>, 'searchParams'>) {
  const { desde } = await searchParams
  return <FormularioEntrar desde={typeof desde === 'string' ? desde : undefined} />
}
  • autoComplete="username" y "current-password" permiten que el gestor de contraseñas del navegador rellene el formulario. No los quites: los gestores de contraseñas son una de las mejores defensas que tienen tus usuarios.
  • defaultValue={estado.email}: tras un error, React 19 restablece el formulario, así que el email se volvería a vaciar. La acción lo devuelve en el estado para que no haya que escribirlo otra vez. La contraseña no se devuelve nunca.
  • searchParams dentro de <Suspense>: con Cache Components, leerlo arriba de la página rompería el build (módulo 04). Como fallback se pinta el mismo formulario sin desde, así que el usuario ve el formulario al instante.

Formulario de inicio de sesión con el email de Ana relleno, la contraseña vacía y el mensaje en rojo "Email o contraseña incorrectos"

Figura 4. Tras una contraseña incorrecta, el email se conserva y aparece un mensaje que no dice cuál de los dos datos falla.

💡 Consejo: El formulario funciona sin JavaScript. El HTML trae campos ocultos ($ACTION_REF_1, $ACTION_1:0, $ACTION_KEY) con el identificador de la acción, y un POST normal del navegador la ejecuta y recibe el 303 con la cookie. Así es como se ha probado con curl en este módulo: se lee el HTML de /entrar, se copian esos campos y se envían junto con el email y la contraseña.

5.3. Cerrar sesión

salir borra la cookie del navegador:

Set-Cookie: sesion=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT

Y salirDeTodo además incrementa versionSesion en la base de datos, lo que invalida todos los tokens emitidos antes para ese usuario, estén en el navegador que estén. Los dos botones van en el menú de la zona de gestión (sección 7.1). En la sección 12.4 verás por qué el segundo es necesario.


6. La capa de acceso a datos (DAL)

6.1. lib/dal/auth.ts

// lib/dal/auth.ts
import 'server-only'
import { cache } from 'react'
import { eq, sql } from 'drizzle-orm'
import { forbidden, redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { usuarios, type Rol } from '@/db/schema'
import { leerSesion } from '@/lib/sesion'
import { cifrarClave, compararClave } from '@/lib/claves'

// DTO: lo que el resto de la aplicación sabe del usuario que ha entrado
export type UsuarioActual = {
  usuarioId: number
  nombre: string
  rol: Rol
  empId: number | null
}

// Lee la cookie, comprueba la firma y busca al usuario en la base de datos.
// cache() hace que, en una misma petición, solo se ejecute una vez
// aunque la llamen el layout, la página y tres componentes.
export const obtenerUsuario = cache(async (): Promise<UsuarioActual | null> => {
  const sesion = await leerSesion()
  if (!sesion) return null

  const [usuario] = await db
    .select({
      usuarioId: usuarios.usuarioId,
      nombre: usuarios.nombre,
      rol: usuarios.rol,
      empId: usuarios.empId,
      versionSesion: usuarios.versionSesion,
    })
    .from(usuarios)
    .where(eq(usuarios.usuarioId, sesion.usuarioId))

  console.log(`[dal] obtenerUsuario ${sesion.usuarioId}`)
  // Usuario borrado, o sesiones revocadas después de emitir este token
  if (!usuario || usuario.versionSesion !== sesion.version) return null

  // El rol sale de la base de datos, no del token: si se le quita
  // el rol ADMIN a alguien, deja de serlo en su siguiente petición.
  return {
    usuarioId: usuario.usuarioId,
    nombre: usuario.nombre,
    rol: usuario.rol,
    empId: usuario.empId,
  }
})

// Para páginas y acciones: sin sesión, a la página de entrada
export async function verificarSesion(): Promise<UsuarioActual> {
  const usuario = await obtenerUsuario()
  if (!usuario) redirect('/entrar')
  return usuario
}

// Para páginas: sin el rol pedido, la página 403 (forbidden.tsx)
export async function exigirRol(rol: Rol): Promise<UsuarioActual> {
  const usuario = await verificarSesion()
  if (usuario.rol !== rol) forbidden()
  return usuario
}

// Hash de una contraseña cualquiera: se compara contra él cuando el email
// no existe, para que la respuesta tarde lo mismo exista o no el usuario.
const hashFicticio = cifrarClave('contraseña-que-nadie-usa')

export async function comprobarCredenciales(email: string, clave: string) {
  const [usuario] = await db
    .select({
      usuarioId: usuarios.usuarioId,
      rol: usuarios.rol,
      hashClave: usuarios.hashClave,
      versionSesion: usuarios.versionSesion,
    })
    .from(usuarios)
    .where(eq(usuarios.email, email.trim().toLowerCase()))

  const valida = await compararClave(clave, usuario?.hashClave ?? (await hashFicticio))
  if (!usuario || !valida) return null
  return { usuarioId: usuario.usuarioId, rol: usuario.rol, version: usuario.versionSesion }
}

// "Cerrar sesión en todos los dispositivos": invalida todos los tokens emitidos
export async function revocarSesiones(usuarioId: number) {
  await db
    .update(usuarios)
    .set({ versionSesion: sql`${usuarios.versionSesion} + 1` })
    .where(eq(usuarios.usuarioId, usuarioId))
}

Tres niveles de función, para tres situaciones:

Función Sin sesión Sin el rol Úsala en
obtenerUsuario() Devuelve null (no mira el rol) Server Actions y Route Handlers, que deciden ellos qué responder; componentes que se pintan distinto con o sin sesión
verificarSesion() redirect('/entrar') (no mira el rol) Páginas de la zona privada
exigirRol('ADMIN') redirect('/entrar') forbidden(): página 403 Páginas solo para un rol

¿Por qué una acción usa obtenerUsuario y no verificarSesion? Porque redirect dentro de una acción llamada desde JavaScript navega a /entrar sin decir nada al usuario. Devolver { ok: false, mensaje: 'Tu sesión ha caducado' } permite mostrar un mensaje en su sitio. Las dos opciones son seguras; es una decisión de experiencia de usuario.

📘 Concepto: La DAL devuelve un DTO (Data Transfer Object): un objeto con solo los campos que el resto de la aplicación necesita. obtenerUsuario selecciona versionSesion para comprobarla, pero no la devuelve; y nunca selecciona hashClave. Si un componente pasara el usuario a un Client Component, solo viajaría lo que hay en el DTO (módulo 22 profundiza en esto).

6.2. cache de React: una consulta por pasada de render

En /gestion/nominas, el usuario actual se necesita en dos sitios: el menú del layout (para pintar el nombre) y la tabla de la página (para comprobar el rol). Sin cache, serían dos consultas a SQLite por petición. cache de React (Curso de React) memoriza el resultado de la función mientras dura un render en el servidor: la segunda llamada recibe la misma promesa.

Para comprobarlo se añadió temporalmente un console.log('[dal] verificarSesion') en verificarSesion (que no está en caché) y se pidió una vez /gestion/nominas como ADMIN. El registro del servidor:

[dal] obtenerUsuario 1
[dal] verificarSesion
[dal] verificarSesion
[db] nominasEnCache 2026-11-02T14:14:04.607Z
[dal] obtenerUsuario 1
[dal] verificarSesion
[dal] verificarSesion

Dos cosas que leer aquí:

  • En cada bloque, verificarSesion se llama dos veces (menú y tabla), pero la consulta (obtenerUsuario) se ejecuta una: cache funciona.
  • El bloque aparece dos veces en una sola petición: en este laboratorio, Next.js 16.4 con Cache Components renderizó dos veces la página ◐ para responder a una petición, y cache de React solo vale dentro de un render. Lo mismo se vio en /gestion/privado, donde solo el layout pide el usuario: dos líneas obtenerUsuario por petición. Por eso sigue habiendo dos consultas por petición en vez de cuatro. Fíjate en que nominasEnCache (con 'use cache', sección 11) solo aparece una vez: la segunda pasada ya la encuentra en la caché.

⚠️ Importante: cache de React no es una caché entre peticiones. Cada petición empieza de cero, y eso es justo lo que quieres para la sesión: el usuario de una petición nunca debe servir para otra. No confundas cache (React, una petición) con 'use cache' (Next, entre peticiones y usuarios).

6.3. Por qué la DAL busca al usuario en la base de datos

Con una sesión sin estado, verificar la firma bastaría para saber quién es el usuario. ¿Para qué consultar la base de datos en cada petición?

  1. El rol puede haber cambiado desde que se emitió el token.
  2. El usuario puede haberse borrado o desactivado.
  3. Las sesiones pueden haberse revocado (versionSesion).

Es una consulta por clave primaria a SQLite: microsegundos. El proxy, en cambio, se ejecuta para cada petición que coincide con su matcher (incluidas las precargas de <Link>), y por eso la guía de Next recomienda que solo lea la cookie, sin base de datos.


7. Proteger páginas

7.1. El layout no protege: cada página se comprueba sola

// app/gestion/layout.tsx
import Link from 'next/link'
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'
import { salir, salirDeTodo } from '@/app/entrar/acciones'

// El layout NO protege a sus páginas: cada página comprueba la sesión por su cuenta.
// Aquí solo se lee la sesión para pintar el menú, y dentro de <Suspense>.
export default function LayoutGestion({ children }: LayoutProps<'/gestion'>) {
  return (
    <div className="flex min-h-[calc(100vh-48px)]">
      <aside className="w-56 bg-indigo-50 border-r p-4 flex flex-col gap-2">
        <Suspense fallback={<p className="text-sm text-slate-500">Cargando usuario...</p>}>
          <MenuUsuario />
        </Suspense>
        <nav className="flex flex-col gap-1 mt-4">
          <Link href="/gestion">Inicio</Link>
          <Link href="/gestion/mi-ficha">Mi ficha</Link>
          <Link href="/gestion/nominas">Nóminas (ADMIN)</Link>
        </nav>
      </aside>
      <main className="flex-1 p-6">{children}</main>
    </div>
  )
}

async function MenuUsuario() {
  const usuario = await verificarSesion()
  return (
    <div className="text-sm">
      <p className="font-semibold">{usuario.nombre}</p>
      <p className="text-xs inline-block bg-indigo-200 rounded px-2 mt-1">{usuario.rol}</p>
      <form action={salir} className="mt-3">
        <button className="text-indigo-700 underline">Cerrar sesión</button>
      </form>
      <form action={salirDeTodo}>
        <button className="text-indigo-700 underline text-xs">Cerrar en todos los dispositivos</button>
      </form>
    </div>
  )
}

¿Por qué no basta con comprobar la sesión en el layout? Porque en el App Router el layout y la página se renderizan en paralelo, no uno detrás de otro. Que el layout redirija no impide que el código de la página se haya ejecutado ya (y con él, quizá, su consulta a la base de datos). Además, al navegar entre páginas hermanas, el layout no se vuelve a renderizar: si la comprobación estuviera solo ahí, no se repetiría. La guía de Next lo dice así: haz las comprobaciones cerca de los datos, no en los layouts.

La página de inicio de la zona privada se protege a sí misma:

// app/gestion/page.tsx
import { Suspense } from 'react'
import { verificarSesion } from '@/lib/dal/auth'

export default function PaginaGestion() {
  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Zona de gestión</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Saludo />
      </Suspense>
    </>
  )
}

async function Saludo() {
  const usuario = await verificarSesion() // la página se protege a sí misma
  return (
    <p>
      Hola, {usuario.nombre}. Has entrado con el rol <strong>{usuario.rol}</strong>.
    </p>
  )
}

Gracias a cache (sección 6.2), que el layout y la página llamen los dos a la DAL no cuesta una consulta más.

7.2. Leer la sesión arriba de un layout rompe el build 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15, leer cookies() arriba de un layout o una página, fuera de <Suspense>, compila sin error y hace que la ruta sea dinámica (ƒ). Si usas Next.js 15: puedes escribir const usuario = await verificarSesion() directamente en el layout o la página; no necesitas <Suspense>, aunque sigue siendo buena idea para que el resto de la página no espere.

Con Cache Components, la sesión es un dato de la petición, y Next exige que se lea dentro de un <Suspense>. Este layout, que parece lo más natural:

// app/demo-error2/layout.tsx  (MAL)
import { verificarSesion } from '@/lib/dal/auth'

// MAL: leer la sesión arriba del layout, fuera de cualquier <Suspense>
export default async function Layout({ children }: LayoutProps<'/demo-error2'>) {
  const usuario = await verificarSesion()
  return (
    <div>
      <p>{usuario.nombre}</p>
      {children}
    </div>
  )
}

hace fallar el build:

Error: Route "/demo-error2": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic

La solución es la del layout de la sección 7.1: mover la lectura a un componente (MenuUsuario) dentro de <Suspense>. Así el armazón de la página (cabecera, menú, títulos) sale prerenderizado al instante, y solo el nombre del usuario espera a la petición. El resultado del build:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/auth/[...all]
├ ƒ /api/empleados/[empId]/nomina
├ ○ /ba/entrar
├ ◐ /ba/panel
├ ◐ /entrar
├ ◐ /gestion
├ ◐ /gestion/mi-ficha
├ ◐ /gestion/nominas
└ ◐ /gestion/privado
ƒ Proxy (Middleware)

La portada (/) sigue siendo estática (○) porque no lee la sesión. Las páginas de gestión son ◐: armazón estático y la parte del usuario en streaming.

7.3. Solo para ADMIN: forbidden()

La página de nóminas llama a una función de la DAL que exige el rol:

// app/gestion/nominas/page.tsx
import { Suspense } from 'react'
import { listarNominas } from '@/lib/dal/empleados'
import { EditorSalario } from './EditorSalario'

export default function PaginaNominas() {
  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Nóminas</h1>
      <Suspense fallback={<p>Cargando nóminas...</p>}>
        <TablaNominas />
      </Suspense>
    </>
  )
}

async function TablaNominas() {
  const filas = await listarNominas() // exige ADMIN: si no, forbidden()
  return (
    <table className="bg-white shadow text-sm">
      <thead className="bg-indigo-100">
        <tr>
          <th className="px-3 py-1 text-left">Id</th>
          <th className="px-3 py-1 text-left">Nombre</th>
          <th className="px-3 py-1 text-left">Oficio</th>
          <th className="px-3 py-1 text-left">Departamento</th>
          <th className="px-3 py-1 text-left">Salario</th>
        </tr>
      </thead>
      <tbody>
        {filas.slice(0, 8).map((f) => (
          <tr key={f.empId} className="border-t">
            <td className="px-3 py-1">{f.empId}</td>
            <td className="px-3 py-1">{f.nombre}</td>
            <td className="px-3 py-1">{f.oficio}</td>
            <td className="px-3 py-1">{f.departamento ?? 'Sin departamento'}</td>
            <td className="px-3 py-1">
              <EditorSalario empId={f.empId} salario={f.salario} />
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

listarNominas (sección 11.2) llama a exigirRol('ADMIN'), que llama a forbidden() si el rol no coincide. forbidden() es experimental y necesita activarse en next.config.ts, como viste en el módulo 14:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    authInterrupts: true, // forbidden() y unauthorized() (módulo 14)
  },
  turbopack: {
    rules: {
      '*.css': {
        loaders: ['@tailwindcss/turbopack'],
        as: '*.css',
      },
    },
  },
}

export default nextConfig

El build lo confirma con Experiments (use with caution): ✓ authInterrupts. Y la página que se muestra es app/forbidden.tsx:

// app/forbidden.tsx
import Link from 'next/link'

export default function Prohibido() {
  return (
    <div className="p-6">
      <h2 className="text-xl font-bold text-red-800">403: no tienes permiso</h2>
      <p className="my-2">Esta página es solo para administración.</p>
      <Link href="/gestion" className="text-indigo-700 underline">Volver a gestión</Link>
    </div>
  )
}

Tabla de nóminas vista por Ana, con el menú lateral que muestra su nombre y el rol ADMIN, y en cada fila un campo con el salario y un enlace Guardar

Figura 5. Ana (ADMIN) ve la tabla de nóminas con el editor de salarios.

Página con el título "403: no tienes permiso" y un enlace para volver a gestión

Figura 6. Luis (USER) en la misma URL: la página de forbidden.tsx.

Ahora lo interesante: ¿qué código HTTP recibe Luis? Comprobado con curl:

== D. USER pide la pagina de ADMIN
200
403: no tienes permiso
<meta name="robots" content="noindex"/>
0

200, no 403. La página contiene la interfaz de forbidden.tsx, la etiqueta noindex, y ningún salario (el 0 es el número de veces que aparece 6500, el sueldo de Ana). La documentación de forbidden lo explica: si se llama después de que empiece el streaming, el estado ya se ha enviado como 200. Como la comprobación está dentro de <Suspense>, el armazón de la página ya había salido. Los datos están protegidos; el código de estado, no. Si necesitas un 403 de verdad (para un cliente que mira el código, no la página), la comprobación tiene que ocurrir antes de enviar nada: en el proxy, como harás en el ejercicio 2.

7.4. Datos del propio usuario

Luis es USER y no puede ver las nóminas de los demás, pero sí la suya:

// app/gestion/mi-ficha/page.tsx
import { Suspense } from 'react'
import { obtenerMiFicha } from '@/lib/dal/empleados'

export default function PaginaMiFicha() {
  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Mi ficha</h1>
      <Suspense fallback={<p>Cargando tu ficha...</p>}>
        <Ficha />
      </Suspense>
    </>
  )
}

async function Ficha() {
  const ficha = await obtenerMiFicha()
  if (!ficha) return <p>Tu usuario no está asociado a ningún empleado.</p>
  return (
    <dl className="grid grid-cols-[10rem_1fr] gap-1 bg-white p-4 shadow w-fit">
      <dt className="font-semibold">Nombre</dt>
      <dd>
        {ficha.nombre} {ficha.apellidos}
      </dd>
      <dt className="font-semibold">Oficio</dt>
      <dd>{ficha.oficio}</dd>
      <dt className="font-semibold">Departamento</dt>
      <dd>{ficha.departamento?.nombre ?? 'Sin departamento'}</dd>
      <dt className="font-semibold">Salario</dt>
      <dd>{ficha.salario} €</dd>
      <dt className="font-semibold">Alta</dt>
      <dd>{ficha.fechaAlta}</dd>
    </dl>
  )
}

obtenerMiFicha() no recibe el empId como argumento. Lo saca de la sesión. Si la página recibiera ?empId=101 en la URL y lo pasara a la DAL, bastaría con cambiar el número para ver la ficha de otro: es el error de autorización más frecuente en aplicaciones reales (se llama IDOR, Insecure Direct Object Reference).

Ficha de Luis Martínez Ruiz: oficio DIRECTOR, departamento VENTAS, salario 4800 euros y fecha de alta 2012-03-01, con el menú lateral que muestra el rol USER

Figura 7. La ficha de Luis, sacada de su propia sesión.


8. Proteger Server Actions

Una Server Action es un endpoint público: cualquiera que conozca su identificador puede llamarla con un POST, aunque el botón que la usa solo aparezca en una página que exige ADMIN (módulo 10). Por eso cada acción comprueba la sesión y el rol en su primera línea.

// app/gestion/acciones.ts
'use server'

import { updateTag } from 'next/cache'
import { z } from 'zod'
import { obtenerUsuario } from '@/lib/dal/auth'
import { actualizarSalario } from '@/lib/dal/empleados'

export type Resultado = { ok: boolean; mensaje: string }

const esquemaSalario = z.object({
  empId: z.number().int().positive(),
  salario: z.number().positive().max(20000),
})

// Una Server Action es un endpoint público: se autoriza ella misma,
// aunque el botón solo aparezca en una página que ya exige ADMIN.
export async function cambiarSalario(empId: unknown, salario: unknown): Promise<Resultado> {
  const usuario = await obtenerUsuario()
  if (!usuario) return { ok: false, mensaje: 'Tu sesión ha caducado: vuelve a entrar' }
  if (usuario.rol !== 'ADMIN') {
    console.log(`[accion] cambiarSalario RECHAZADA para el usuario ${usuario.usuarioId}`)
    return { ok: false, mensaje: 'Solo administración puede cambiar salarios' }
  }

  const datos = esquemaSalario.safeParse({ empId, salario })
  if (!datos.success) return { ok: false, mensaje: 'Datos no válidos' }

  const fila = await actualizarSalario(datos.data.empId, datos.data.salario)
  if (!fila) return { ok: false, mensaje: 'Ese empleado no existe' }

  console.log(`[accion] cambiarSalario ${fila.empId} -> ${fila.salario} por ${usuario.usuarioId}`)
  updateTag('nominas')
  return { ok: true, mensaje: 'Guardado' }
}
  • Los parámetros son unknown, no number. TypeScript no protege nada en tiempo de ejecución: quien llama con curl puede enviar cualquier cosa. Zod convierte unknown en datos con tipo, o los rechaza.
  • Primero quién, después qué. La comprobación de sesión y rol va antes de validar los datos: a un desconocido no hace falta decirle si sus datos tenían el formato correcto.
  • updateTag('nominas') renueva la caché de la tabla (sección 11). En Next.js 15 sería revalidateTag('nominas') (módulo 09).

El editor que la llama, en el cliente:

// app/gestion/nominas/EditorSalario.tsx
'use client'

import { useState, useTransition } from 'react'
import { cambiarSalario } from '../acciones'

export function EditorSalario({ empId, salario }: { empId: number; salario: number }) {
  const [valor, setValor] = useState(String(salario))
  const [mensaje, setMensaje] = useState('')
  const [pendiente, iniciar] = useTransition()

  return (
    <span className="flex gap-2 items-center">
      <input
        aria-label={`Salario de ${empId}`}
        value={valor}
        onChange={(e) => setValor(e.target.value)}
        className="border rounded px-1 w-20"
        inputMode="decimal"
      />
      <button
        disabled={pendiente}
        className="text-indigo-700 underline disabled:opacity-50"
        onClick={() =>
          iniciar(async () => {
            const r = await cambiarSalario(empId, Number(valor))
            setMensaje(r.mensaje)
          })
        }
      >
        Guardar
      </button>
      {mensaje && <span className="text-xs text-slate-500">{mensaje}</span>}
    </span>
  )
}

Los ataques con curl contra esta acción están en la sección 12.3.


9. Proteger Route Handlers

Un Route Handler tampoco hereda ninguna protección. Y aquí no tiene sentido redirigir a /entrar: quien llama a una API (una app móvil, un script) espera un código HTTP y un JSON. La DAL ofrece una función que decide sin redirigir:

// lib/dal/empleados.ts (fragmento; el fichero completo está en la sección 11.2)

// Para el Route Handler: no redirige, devuelve el código HTTP que corresponde
type ResultadoNomina =
  | { estado: 401 | 403 | 404 }
  | { estado: 200; ficha: NonNullable<Awaited<ReturnType<typeof fichaEnCache>>> }

export async function nominaPara(empId: number): Promise<ResultadoNomina> {
  const usuario = await obtenerUsuario()
  if (!usuario) return { estado: 401 } // no sabemos quién eres
  if (!puedeVerNomina(usuario, empId)) return { estado: 403 } // sabemos quién eres: no puedes
  const ficha = await fichaEnCache(empId)
  return ficha ? { estado: 200, ficha } : { estado: 404 }
}

function puedeVerNomina(usuario: UsuarioActual, empId: number) {
  return usuario.rol === 'ADMIN' || usuario.empId === empId
}
// app/api/empleados/[empId]/nomina/route.ts
import { nominaPara } from '@/lib/dal/empleados'

const MENSAJES = {
  401: 'Inicia sesión',
  403: 'No puedes ver esta nómina',
  404: 'Ese empleado no existe',
} as const

export async function GET(_request: Request, ctx: RouteContext<'/api/empleados/[empId]/nomina'>) {
  const { empId } = await ctx.params
  const id = Number(empId)
  if (!Number.isInteger(id)) return Response.json({ error: 'Identificador no válido' }, { status: 400 })

  const resultado = await nominaPara(id)
  if (resultado.estado === 200) return Response.json(resultado.ficha)
  return Response.json({ error: MENSAJES[resultado.estado] }, { status: resultado.estado })
}

La regla de autorización (puedeVerNomina) está en la DAL, no en el handler. Si mañana una página o una acción necesitan la misma regla, la reutilizan; si la regla cambia (por ejemplo, que el jefe de departamento vea las nóminas de su equipo), se cambia en un sitio.

Resultado real, con cada combinación:

== K. Route Handler
HTTP/1.1 401 Unauthorized
{"empId":101,"nombre":"Luis","apellidos":"Martínez Ruiz","oficio":"DIRECTOR","salario":4800,"comision":null,"fechaAlta":"2012-03-01","departamento":{"nombre":"VENTAS"}} [200]
{"error":"No puedes ver esta nómina"} [403]
{"error":"Ese empleado no existe"} [404]
{"error":"Identificador no válido"} [400]

En orden: sin cookie (401); Luis pide la suya, la 101 (200); Luis pide la de Ana, la 100 (403); Ana pide la 999 (404); Ana pide abc (400).

💡 Consejo: ¿Debería Luis recibir 404 en vez de 403 al pedir la nómina de otro? Hay quien lo prefiere, para no confirmar que el empleado 100 existe. En una aplicación interna como esta, donde los identificadores no son secretos, el 403 es más claro. En una API pública con identificadores de clientes, el 404 filtra menos información.


10. El proxy como primera barrera 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero se llama middleware.ts y la función middleware; por defecto se ejecuta en el runtime Edge. Si usas Next.js 15: crea middleware.ts con export async function middleware(request: NextRequest) y el mismo código; jose funciona en Edge. Importa jwtVerify de jose directamente en lugar de lib/sesion.ts, que usa next/headers y server-only.

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getSessionCookie } from 'better-auth/cookies'
import { COOKIE_SESION, descifrarSesion } from '@/lib/sesion'

// Primera barrera, OPTIMISTA: solo mira la cookie (firma y caducidad),
// sin tocar la base de datos. La comprobación de verdad está en la DAL.
export async function proxy(request: NextRequest) {
  const { pathname, search } = request.nextUrl

  // Zona de Better Auth: solo comprueba que exista su cookie
  if (pathname.startsWith('/ba/panel')) {
    if (!getSessionCookie(request)) {
      return NextResponse.redirect(new URL('/ba/entrar', request.url))
    }
    return NextResponse.next()
  }

  const sesion = await descifrarSesion(request.cookies.get(COOKIE_SESION)?.value)

  if (pathname.startsWith('/gestion') && !sesion) {
    const url = new URL('/entrar', request.url)
    url.searchParams.set('desde', pathname + search)
    return NextResponse.redirect(url)
  }

  if (pathname === '/entrar' && sesion) {
    return NextResponse.redirect(new URL('/gestion', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/gestion/:path*', '/entrar', '/ba/panel/:path*'],
}

Qué aporta:

  • Mejor experiencia. Sin sesión, el usuario va directo a /entrar con un 307, sin que se llegue a enviar la página de gestión. Y quien ya ha entrado no ve el formulario de nuevo.
  • Guarda adónde iba (desde), para volver allí tras iniciar sesión.
  • Coste mínimo. Verificar una firma HMAC son microsegundos; no hay consulta a la base de datos.

Comprobado:

== A. Sin cookie a una pagina privada
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas
== N. Proxy: ya con sesion, /entrar redirige
HTTP/1.1 307 Temporary Redirect location: /gestion

Y el viaje completo en el navegador: al abrir /gestion/nominas sin sesión, Playwright acabó en http://127.0.0.1:5517/entrar?desde=%2Fgestion%2Fnominas, y tras iniciar sesión, en http://127.0.0.1:5517/gestion/nominas.

10.1. Por qué el proxy no basta

El proxy es una comodidad, no la seguridad. Hay muchas formas de que una petición no pase por él o de que lo que comprueba no sea suficiente:

  1. El matcher. Este proxy no se ejecuta en /api/.... Un Route Handler nuevo, o una página fuera de /gestion, queda sin proteger si solo confías en él. Por eso el handler de la sección 9 se protege solo.
  2. Es optimista. No sabe si el usuario sigue existiendo, si su rol ha cambiado ni si sus sesiones se han revocado. En la sección 12.4 verás que deja pasar una cookie revocada.
  3. Un fallo en el propio framework. En marzo de 2025 se publicó la vulnerabilidad CVE-2025-29927: enviando una cabecera interna de Next, x-middleware-subrequest, se podía saltar el middleware por completo en aplicaciones autoalojadas con next start. Se corrigió en Next.js 15.2.3 y 14.2.25 (con parches para 13.5.9 y 12.3.5). En el análisis que publicó Vercel tras el incidente se lee: “We do not recommend Middleware to be the sole method of protecting routes in your application”. Quien tenía la autorización solo en el middleware quedó expuesto; quien comprobaba también en la capa de datos, no.

Se probó el ataque contra el laboratorio (Next.js 16.4.3), con los valores de cabecera que se usaban entonces:

curl -s -o /dev/null -D - -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" http://127.0.0.1:5517/gestion/nominas
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas

Con middleware:..., proxy:... y src/middleware:..., el proxy se sigue ejecutando: la versión actual no es vulnerable. Pero la lección permanece: actualiza siempre al último parche (módulo 22) y no pongas toda la seguridad en una sola capa.

⚠️ Importante: No consultes la base de datos en el proxy. Se ejecuta en cada petición que coincide con el matcher, incluidas las precargas de cada <Link> visible. Con una consulta por precarga, una página con veinte enlaces a la zona privada lanzaría veinte consultas solo por mostrarse.

10.2. Comprobado en Next.js 15

El middleware.ts equivalente, compilado con next build --turbopack y ejecutado con next start en Next.js 15.5.28:

// middleware.ts (Next.js 15)
import { NextResponse, type NextRequest } from 'next/server'
import { jwtVerify } from 'jose'

const clave = new TextEncoder().encode(process.env.SESSION_SECRET)

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('sesion')?.value
  let valida = false
  if (token) {
    try {
      await jwtVerify(token, clave, { algorithms: ['HS256'] })
      valida = true
    } catch {}
  }
  if (!valida) {
    const url = new URL('/entrar', request.url)
    url.searchParams.set('desde', request.nextUrl.pathname)
    return NextResponse.redirect(url)
  }
  return NextResponse.next()
}

export const config = { matcher: ['/gestion/:path*'] }

El build de 15 lo lista como ƒ Middleware 44.2 kB, y sin cookie responde 307 a /entrar?desde=%2Fgestion; con el token emitido por la aplicación de 16 (mismo secreto), deja pasar.


11. Autenticación y Cache Components 🆕 16.0

🆕 Desde Next.js 16.0. 'use cache', cacheTag, cacheLife y 'use cache: private' no existen en Next.js 15. Si usas Next.js 15: lee la sesión con cookies() donde quieras (la ruta pasa a ser dinámica, ƒ) y guarda en caché los datos por usuario con unstable_cache(fn, ['clave'], { tags, revalidate }), pasando el identificador como argumento (apartado 11.4). No hay equivalente de 'use cache: private'.

11.1. Lo que hay dentro de 'use cache' no puede leer cookies

Una función con 'use cache' guarda su resultado y lo comparte entre todas las peticiones y todos los usuarios que la llamen con los mismos argumentos. Si pudiera leer la cookie de sesión, el panel de Ana acabaría en la caché y se serviría a Luis. Next lo impide. Este código:

// app/demo-error1/page.tsx  (MAL)
import { Suspense } from 'react'
import { leerSesion } from '@/lib/sesion'

// MAL: una función con 'use cache' que lee la cookie de sesión
async function miPanelEnCache() {
  'use cache'
  const sesion = await leerSesion()
  return `Panel del usuario ${sesion?.usuarioId}`
}

export default function Pagina() {
  return (
    <Suspense fallback={<p>...</p>}>
      <Panel />
    </Suspense>
  )
}

async function Panel() {
  return <p>{await miPanelEnCache()}</p>
}

hace fallar el build, aunque la lectura esté escondida dentro de otra función (leerSesion) de otro fichero:

⨯ Error: Route "/demo-error1": `cookies()` can't be read inside `"use cache"`. Read it outside the cached function and pass what you need as an argument.
Learn more: https://nextjs.org/docs/messages/next-request-in-use-cache
    at k (lib\sesion.ts:58:25)
    at g (app\demo-error1\page.tsx:7:24)
  56 |
  57 | export async function leerSesion(): Promise<DatosSesion | null> {
> 58 |   const almacen = await cookies()
     |                         ^
  59 |   return descifrarSesion(almacen.get(COOKIE_SESION)?.value)
  60 | }

Lo mismo pasa con headers(), y por tanto con auth.api.getSession({ headers: await headers() }) de Better Auth: la sesión nunca se lee dentro de 'use cache'.

11.2. El patrón: autorizar fuera, leer de la caché dentro

El mensaje de error da la solución: lee la sesión fuera y pasa a la función en caché solo lo que necesita. Así queda lib/dal/empleados.ts completo:

// lib/dal/empleados.ts
import 'server-only'
import { asc, eq } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
import { departamentos, empleados } from '@/db/schema'
import { exigirRol, obtenerUsuario, verificarSesion, type UsuarioActual } from './auth'

export type FilaNomina = {
  empId: number
  nombre: string
  oficio: string
  salario: number
  departamento: string | null
}

// ---------------------------------------------------------------------------
// Funciones en caché: NO leen cookies ni comprueban permisos.
// No se exportan, para que nadie pueda llamarlas saltándose la comprobación.
// ---------------------------------------------------------------------------

async function nominasEnCache(): Promise<FilaNomina[]> {
  'use cache'
  cacheTag('nominas')
  cacheLife('hours')
  console.log(`[db] nominasEnCache ${new Date().toISOString()}`)
  return db
    .select({
      empId: empleados.empId,
      nombre: empleados.nombre,
      oficio: empleados.oficio,
      salario: empleados.salario,
      departamento: departamentos.nombre,
    })
    .from(empleados)
    .leftJoin(departamentos, eq(empleados.deptId, departamentos.deptId))
    .orderBy(asc(empleados.empId))
}

async function fichaEnCache(empId: number) {
  'use cache'
  cacheTag('nominas', `empleado-${empId}`)
  cacheLife('hours')
  console.log(`[db] fichaEnCache ${empId}`)
  const ficha = await db.query.empleados.findFirst({
    where: eq(empleados.empId, empId),
    columns: { empId: true, nombre: true, apellidos: true, oficio: true, salario: true, comision: true, fechaAlta: true },
    with: { departamento: { columns: { nombre: true } } },
  })
  return ficha ?? null
}

// ---------------------------------------------------------------------------
// Funciones públicas de la DAL: primero autorizan, después leen (de la caché)
// ---------------------------------------------------------------------------

export async function listarNominas() {
  await exigirRol('ADMIN')
  return nominasEnCache()
}

export async function obtenerMiFicha() {
  const usuario = await verificarSesion()
  if (usuario.empId === null) return null
  return fichaEnCache(usuario.empId)
}

// Para el Route Handler: no redirige, devuelve el código HTTP que corresponde
type ResultadoNomina =
  | { estado: 401 | 403 | 404 }
  | { estado: 200; ficha: NonNullable<Awaited<ReturnType<typeof fichaEnCache>>> }

export async function nominaPara(empId: number): Promise<ResultadoNomina> {
  const usuario = await obtenerUsuario()
  if (!usuario) return { estado: 401 } // no sabemos quién eres
  if (!puedeVerNomina(usuario, empId)) return { estado: 403 } // sabemos quién eres: no puedes
  const ficha = await fichaEnCache(empId)
  return ficha ? { estado: 200, ficha } : { estado: 404 }
}

function puedeVerNomina(usuario: UsuarioActual, empId: number) {
  return usuario.rol === 'ADMIN' || usuario.empId === empId
}

// Mutación: la llama una Server Action que ya ha comprobado el rol
export async function actualizarSalario(empId: number, salario: number) {
  const [fila] = await db
    .update(empleados)
    .set({ salario })
    .where(eq(empleados.empId, empId))
    .returning({ empId: empleados.empId, salario: empleados.salario })
  return fila ?? null
}

Las reglas que sigue:

  1. La comprobación nunca va en caché; los datos sí. listarNominas comprueba el rol en cada petición (a través de exigirRol) y solo después pide los datos a nominasEnCache, que puede servirlos de memoria. Si fuera al revés (la comprobación dentro de la función en caché), la primera llamada de un ADMIN dejaría el resultado guardado y la siguiente, de un USER, lo recibiría sin comprobar nada.
  2. Las funciones en caché no se exportan. Si fichaEnCache fuera pública, cualquier página podría llamarla con el empId de otro saltándose puedeVerNomina. La guía de Next lo recomienda expresamente.
  3. La clave de la caché es lo que determina los datos. fichaEnCache(101) y fichaEnCache(102) son entradas distintas, porque los argumentos forman parte de la clave. La lista de nóminas es la misma para todos los ADMIN, así que no lleva argumentos: una sola entrada compartida.
  4. Nada sensible en argumentos ni etiquetas. La documentación avisa de que las claves de caché y las etiquetas se guardan como texto plano. Un identificador numérico, sí; un email, un token o una contraseña, nunca.

Que la caché funciona se ve en el registro del servidor: durante la batería de pruebas de la sección 12 se pidió /api/empleados/101/nomina cinco veces, y [db] fichaEnCache 101 solo aparece una. Y la Server Action de la sección 8, con updateTag('nominas'), invalida las dos funciones a la vez (las dos llevan la etiqueta nominas): tras cambiar un salario, el registro muestra de nuevo [db] nominasEnCache con la hora nueva.

11.3. 'use cache: private': caché en el navegador de cada usuario 🆕 16.0

🆕 Desde Next.js 16.0. No existe en Next.js 15. Si usas Next.js 15: no hay equivalente; cada navegación vuelve a pedir al servidor los datos que dependen de la sesión.

Hay una variante que sí puede leer cookies(): 'use cache: private'. Su resultado no se guarda en el servidor, sino solo en la caché del navegador de ese usuario, y sirve para que Next pueda precargar el contenido personalizado y la navegación sea instantánea (la guía authentication-with-cache-components.md de la documentación local lo explica con detalle).

// app/gestion/privado/page.tsx
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
import { cookies } from 'next/headers'
import { COOKIE_SESION, descifrarSesion } from '@/lib/sesion'

// 'use cache: private' SÍ puede leer cookies(): el resultado se guarda
// solo en la caché del navegador de ese usuario, nunca en el servidor.
async function resumenPrivado() {
  'use cache: private'
  cacheLife({ stale: 60 })
  const sesion = await descifrarSesion((await cookies()).get(COOKIE_SESION)?.value)
  return { usuarioId: sesion?.usuarioId ?? null, generado: new Date().toISOString() }
}

export default function PaginaPrivada() {
  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Caché privada</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Resumen />
      </Suspense>
    </>
  )
}

async function Resumen() {
  const { usuarioId, generado } = await resumenPrivado()
  return (
    <p>
      Usuario {usuarioId ?? 'anónimo'}, calculado a las {generado}
    </p>
  )
}

Compila sin error (aparece como ◐ /gestion/privado) y, pedida con la cookie de Ana, devuelve Usuario 1, calculado a las 2026-11-02T14:12:16.226Z. Ten en cuenta dos límites de la documentación: dentro de 'use cache: private' no se puede llamar a connection(), y si ajustas cacheLife, el stale debe ser de 30 segundos o más para que el contenido entre en la precarga. Para este módulo basta con saber que existe; los datos compartidos entre usuarios siguen yendo con 'use cache' normal.

11.4. El equivalente en Next.js 15

Compilado y ejecutado en Next.js 15.5.28:

// app/gestion/page.tsx (Next.js 15)
import { cookies } from 'next/headers'
import { unstable_cache } from 'next/cache'
import { jwtVerify } from 'jose'

const clave = new TextEncoder().encode(process.env.SESSION_SECRET)

// Next.js 15: cache por usuario con unstable_cache; el id forma parte de la clave
const fichaEnCache = unstable_cache(
  async (empId: number) => ({ empId, generado: new Date().toISOString() }),
  ['ficha-empleado'],
  { tags: ['nominas'], revalidate: 3600 },
)

export default async function Pagina() {
  const token = (await cookies()).get('sesion')?.value ?? ''
  const { payload } = await jwtVerify<{ usuarioId: number }>(token, clave, { algorithms: ['HS256'] })
  const ficha = await fichaEnCache(payload.usuarioId)
  return <p>Ficha {ficha.empId} generada a las {ficha.generado}</p>
}

El build de 15 marca la ruta como ƒ /gestion (dinámica, por leer cookies()), y la respuesta con el token de Ana es Ficha 1 generada a las 2026-11-02T14:25:55.085Z. La idea es la misma que en 16: la sesión se lee fuera de la función en caché y el identificador entra como argumento, que unstable_cache añade a la clave ('ficha-empleado').


12. Atacar tu propia aplicación

Un sistema de autenticación no está terminado hasta que has intentado romperlo. Esta sección recoge una batería de ataques reales con curl contra la aplicación construida con next build y next start. Todos los resultados son los obtenidos.

12.1. Cómo se preparan los ataques

Dos utilidades. La primera inicia sesión enviando el formulario de /entrar sin JavaScript (como explica el consejo de la sección 5.2) y guarda el token de la cabecera Set-Cookie:

# pruebas/comun.sh
BASE=http://127.0.0.1:5517
ORIGEN="Origin: $BASE"

entrar() {
  local html id clave
  html=$(curl -s "$BASE/entrar")
  id=$(echo "$html" | grep -o 'ACTION_1:0" value="{&quot;id&quot;:&quot;[0-9a-f]*' | head -1 | sed 's/.*&quot;//')
  clave=$(echo "$html" | grep -o 'ACTION_KEY" value="[^"]*' | head -1 | sed 's/.*value="//')
  curl -s -i -X POST "$BASE/entrar" -H "$ORIGEN" \
    --form-string '$ACTION_REF_1=' \
    --form-string "\$ACTION_1:0={\"id\":\"$id\",\"bound\":\"\$@1\"}" \
    --form-string '$ACTION_1:1=[{}]' \
    --form-string "\$ACTION_KEY=$clave" \
    --form-string "email=$1" --form-string "clave=$2" > pruebas/tmp/respuesta-entrar.txt
  TOKEN=$(grep -i '^set-cookie: sesion=' pruebas/tmp/respuesta-entrar.txt | sed 's/^[^=]*=//; s/;.*//')
}

# Identificador de una Server Action por su nombre, sacado del manifiesto del build
id_accion() {
  node -e "
    const m = require('./.next/server/server-reference-manifest.json');
    for (const [id, a] of Object.entries(m.node)) if (a.exportedName === '$1') { console.log(id); break }
  "
}

La segunda fabrica tokens falsos con jose:

// pruebas/fabricar.mjs
import { readFileSync } from 'node:fs'
import { SignJWT } from 'jose'

const [modo, token] = process.argv.slice(2)
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const ahora = Math.floor(Date.now() / 1000)
const admin = { usuarioId: 1, rol: 'ADMIN', version: 1, sub: '1', iat: ahora, exp: ahora + 3600 }

if (modo === 'manipulado') {
  // Token real de un USER: cambiamos su rol a ADMIN y dejamos la firma original
  const [cabecera, contenido, firma] = token.split('.')
  const datos = JSON.parse(Buffer.from(contenido, 'base64url').toString())
  datos.rol = 'ADMIN'
  console.log([cabecera, b64(datos), firma].join('.'))
} else if (modo === 'none') {
  // Sin firma: "alg": "none"
  console.log([b64({ alg: 'none' }), b64(admin), ''].join('.'))
} else if (modo === 'otro-secreto') {
  const clave = new TextEncoder().encode('un-secreto-que-el-servidor-no-conoce-1234')
  console.log(await new SignJWT(admin).setProtectedHeader({ alg: 'HS256' }).sign(clave))
} else if (modo === 'caducado') {
  // Con el secreto bueno (como si se hubiera filtrado un token antiguo)
  const env = readFileSync('.env.local', 'utf8')
  const secreto = env.match(/^SESSION_SECRET=(.*)$/m)[1].trim()
  const clave = new TextEncoder().encode(secreto)
  console.log(
    await new SignJWT({ ...admin, iat: ahora - 7200, exp: ahora - 60 })
      .setProtectedHeader({ alg: 'HS256' })
      .sign(clave),
  )
}

⚠️ Importante: Si haces estas pruebas en Windows con Git Bash, exporta antes MSYS_NO_PATHCONV=1. Sin ella, Git Bash “traduce” a rutas de Windows los argumentos que empiezan por /: desde=/gestion/mi-ficha llega al servidor como una ruta de disco y //malo.example como /malo.example. En las primeras pruebas de este módulo eso hizo creer que destinoSeguro fallaba, cuando el problema estaba en la terminal.

12.2. Tokens falsificados

Luis (USER) inicia sesión, se copia su token, cambia "rol":"USER" por "rol":"ADMIN" y lo vuelve a codificar, dejando la firma original:

== E. Token de USER con el rol cambiado a ADMIN
payload: {"usuarioId":2,"rol":"ADMIN","version":3,"sub":"2","iat":1793628636,"exp":1793657436}
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas
HTTP/1.1 401 Unauthorized
== F. alg none
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas
== G. Firmado con otro secreto
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas
== H. Caducado (firmado con el secreto bueno)
HTTP/1.1 307 Temporary Redirect location: /entrar?desde=%2Fgestion%2Fnominas

Las cuatro falsificaciones fracasan: el proxy las trata como “sin sesión” y el Route Handler responde 401. En el registro del servidor queda el motivo exacto, gracias al code de los errores de jose:

[sesion] token rechazado: ERR_JWS_SIGNATURE_VERIFICATION_FAILED
[sesion] token rechazado: ERR_JOSE_ALG_NOT_ALLOWED
[sesion] token rechazado: ERR_JWT_EXPIRED

ERR_JWS_SIGNATURE_VERIFICATION_FAILED aparece para el token manipulado y para el firmado con otro secreto; ERR_JOSE_ALG_NOT_ALLOWED, para "alg": "none"; ERR_JWT_EXPIRED, para el caducado.

💡 Consejo: La primera versión de descifrarSesion registraba error.name en lugar de error.code, y en producción salían cosas como token rechazado: np o token rechazado: nu: el bundler acorta los nombres de las clases al minimizar. El código (ERR_...) es una cadena que forma parte de la API de jose y sobrevive a la minimización.

12.3. Llamar a la Server Action a mano

El identificador de cambiarSalario se saca del manifiesto del build (o de la pestaña Red del navegador). Con él, se llama a la acción con distintas cookies:

curl -s -X POST "$BASE/gestion/nominas" -H "$ORIGEN" -H "Next-Action: $ACCION" \
  -H "Accept: text/x-component" -H "Content-Type: text/plain;charset=UTF-8" \
  -H "Cookie: sesion=$LUIS" --data '[105,9999]'
== I. Server Action cambiarSalario llamada a mano
id: 6096de6a5d8e3a1519e30c8a5f108fa0900422a6bb
-- como USER:
{"ok":false,"mensaje":"Solo administración puede cambiar salarios"}
[200]
-- sin cookie:
[307]
-- como ADMIN:
{"ok":true,"mensaje":"Guardado"}
[200]
-- como ADMIN, datos malos:
{"ok":false,"mensaje":"Datos no válidos"}
[200]
  • Como USER, la petición pasa el proxy (tiene una sesión válida), pero la acción la rechaza. El registro dice [accion] cambiarSalario RECHAZADA para el usuario 2. Si la acción no comprobara el rol, Luis se habría subido el sueldo con una línea de terminal, aunque nunca vea el botón.
  • Sin cookie, es el proxy el que corta (307), porque la acción se llama en una URL de /gestion. No cuentes con ello: la misma acción se podría usar en una página fuera del matcher, y entonces el único freno sería su propia comprobación.
  • Con datos malos (["105; DROP TABLE empleados",-5]), Zod la rechaza antes de tocar la base de datos.

Supón que alguien consigue una copia de la cookie de Luis (un ordenador compartido, un malware). Luis se da cuenta y pulsa “Cerrar sesión”:

== L. Cookie robada y cierre de sesion
Set-Cookie: sesion=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
copia robada tras salir():
200
copia robada tras salirDeTodo():
401
el proxy la sigue dejando pasar:
HTTP/1.1 200 OK

Es la debilidad de fondo de las sesiones sin estado:

  1. salir() solo borra la cookie del navegador de Luis. La copia robada sigue siendo un JWT válido, firmado y sin caducar: el Route Handler responde 200 con su nómina. El servidor no tiene ningún registro que borrar.
  2. salirDeTodo() sube versionSesion en la base de datos. El token robado lleva version: 3, la tabla ya dice 4, y la DAL lo rechaza: 401.
  3. El proxy no se entera. Verifica la firma (que sigue siendo válida) y deja pasar. La página se sirve con 200, pero la DAL redirige dentro del stream a /entrar y no se muestra ningún dato. Es la diferencia entre comprobación optimista y comprobación de verdad.

Comparación de dos columnas: la sesión sin estado guarda los datos en el JWT, se verifica sin base de datos y no se puede invalidar al cerrar sesión sin un mecanismo extra; la sesión con estado guarda solo una llave en la cookie y los datos en la tabla session, de modo que cerrar sesión borra la fila

Figura 8. La versión de sesión convierte la sesión sin estado en “casi con estado”: a cambio de una consulta en la DAL, se puede revocar.

12.5. Redirección abierta y CSRF

== M. Redireccion abierta tras el login
desde=https://malo.example -> Location: /gestion
desde=//malo.example -> Location: /gestion
desde=/gestion/mi-ficha -> Location: /gestion/mi-ficha

destinoSeguro solo acepta rutas propias. Sin esa función, la segunda línea enviaría a la víctima a //malo.example, que el navegador interpreta como https://malo.example.

Y una petición a la acción desde otro origen, con la cookie de Ana (lo que haría una página maliciosa si el navegador enviara la cookie):

== J. CSRF: ADMIN, pero desde otro origen
500
`x-forwarded-host` header with value `127.0.0.1:5517` does not match `origin` header with value `malo.example` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.

Next compara la cabecera Origin con el host y aborta la acción antes de ejecutarla. Es una segunda defensa contra CSRF, además de sameSite: 'lax', que ya impide que el navegador envíe la cookie en un POST desde otro sitio. Los Route Handlers no tienen esta comprobación: si un handler cambia datos, compruébala tú (ejercicio 3).

12.6. Resumen de la batería

Ataque Quién lo para Resultado
Página privada sin cookie Proxy 307 a /entrar?desde=...
Contraseña mala / email inexistente Acción entrar Mismo mensaje, tiempos sin patrón
USER en la página de ADMIN DAL (exigirRol) Interfaz 403 con estado 200 y noindex, sin datos
Token con el rol cambiado jwtVerify (firma) 307 / 401
alg: none, otro secreto, caducado jwtVerify 307 / 401
USER llama a la acción de ADMIN La propia acción { ok: false }
Datos maliciosos en la acción Zod { ok: false, mensaje: 'Datos no válidos' }
Acción desde otro origen Next (comprobación de Origin) 500, la acción no se ejecuta
USER pide la nómina de otro por la API DAL (puedeVerNomina) 403
Cookie robada tras salir() Nadie 200: el token sigue valiendo
Cookie robada tras salirDeTodo() DAL (versionSesion) 401
desde hacia otro sitio destinoSeguro Redirige a /gestion
Cabecera x-middleware-subrequest Next.js (corregido en 2025) El proxy se ejecuta: 307

13. Better Auth

13.1. Qué es y por qué

Better Auth es una biblioteca de autenticación para TypeScript, independiente del framework, que se instala en tu servidor y guarda los usuarios en tu base de datos. Trae resuelto lo que en la versión propia tendrías que programar: registro, inicio de sesión con email y contraseña, sesiones en base de datos, límite de intentos, verificación de email, recuperación de contraseña, inicio de sesión con Google o GitHub, dos factores, organizaciones… (estas últimas mediante plugins). Se integra con Next mediante un Route Handler y un plugin para las cookies, y con Drizzle mediante un adaptador.

La versión del curso es la 1.7.15 (npm view better-auth dist-tags daba latest: '1.7.15' el 2 de noviembre de 2026).

13.2. Configuración

Las opciones van en un fichero aparte, sin server-only, por un motivo que verás en la sección 13.3:

// lib/auth-opciones.ts
// Opciones de Better Auth compartidas por la aplicación (lib/auth.ts)
// y por la configuración que lee la CLI (auth.config.ts). Sin 'server-only'.
import type { BetterAuthOptions } from 'better-auth'

export const opcionesAuth = {
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 10,
  },
  user: {
    additionalFields: {
      rol: { type: 'string', required: false, defaultValue: 'USER', input: false },
    },
  },
  session: {
    expiresIn: 60 * 60 * 8, // 8 horas
  },
} satisfies BetterAuthOptions
// lib/auth.ts
import 'server-only'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { db } from '@/lib/db'
import * as esquemaAuth from '@/db/auth-schema'
import { opcionesAuth } from './auth-opciones'

// BETTER_AUTH_SECRET y BETTER_AUTH_URL se leen solos de las variables de entorno
export const auth = betterAuth({
  ...opcionesAuth,
  database: drizzleAdapter(db, { provider: 'sqlite', schema: esquemaAuth }),
  plugins: [nextCookies()], // debe ser el último plugin
})

export type SesionBA = typeof auth.$Infer.Session
  • drizzleAdapter(db, { provider: 'sqlite', schema }): Better Auth usa el mismo cliente de Drizzle que el resto de la aplicación (lib/db del módulo 16) y las tablas que le pases.
  • additionalFields.rol: añade la columna rol a la tabla de usuarios de Better Auth. Con input: false, nadie puede elegir su rol al registrarse: lo comprobarás en la sección 13.6. Better Auth tiene también un plugin admin con roles, bloqueo de usuarios e impersonación; para dos roles basta con este campo.
  • nextCookies(): cuando llamas a Better Auth desde una Server Action (auth.api.signInEmail(...)), la cookie tiene que escribirse con cookies() de Next; este plugin lo hace. La documentación de Better Auth pide que sea el último de la lista.

13.3. Las tablas: la CLI y server-only

Better Auth necesita cuatro tablas: user, session, account (contraseñas y cuentas de Google, GitHub…) y verification (códigos de un solo uso). Su CLI genera el esquema de Drizzle a partir de tu configuración. El paquete de la CLI se llama auth:

npx auth@1.7.15 generate --config lib/auth.ts --output db/auth-schema.ts --yes
[#better-auth]: Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI.

La CLI carga tu fichero de configuración fuera de Next, y server-only lanza un error en cualquier sitio que no sea un Server Component. Quitar la línea “temporalmente” no basta, porque lib/auth.ts importa lib/db, que también lleva server-only. La solución limpia es un fichero de configuración solo para la CLI, que comparte las opciones pero crea su propio cliente de Drizzle:

// auth.config.ts
// Solo para la CLI de Better Auth (npx auth generate): no lo importa la aplicación.
// La CLI no puede cargar módulos con 'server-only', así que aquí se crea
// un cliente de Drizzle propio en lugar de importar lib/db.
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { opcionesAuth } from './lib/auth-opciones'

const db = drizzle(createClient({ url: process.env.DATABASE_URL ?? 'file:empresa.db' }))

export const auth = betterAuth({
  ...opcionesAuth,
  database: drizzleAdapter(db, { provider: 'sqlite' }),
})
npx auth@1.7.15 generate --config auth.config.ts --output db/auth-schema.ts --yes
ERROR [Better Auth]: Drizzle schema mismatch

  Missing tables
    user, session, account, verification

  help: Run `npx auth generate` to refresh the Drizzle schema, then apply it with your migration tool.
- preparing schema...
🚀 Schema was generated successfully!

El ERROR del principio es esperable (las tablas aún no existen: es justo lo que vas a generar) y no impide que termine bien. El esquema generado (fragmento):

// db/auth-schema.ts (generado por la CLI; fragmento)
export const user = sqliteTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: integer("email_verified", { mode: "boolean" })
    .default(false)
    .notNull(),
  image: text("image"),
  createdAt: integer("created_at", { mode: "timestamp_ms" })
    .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
    .notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp_ms" })
    .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
    .$onUpdate(() => /* @__PURE__ */ new Date())
    .notNull(),
  rol: text("rol").default("USER"),
});

export const session = sqliteTable(
  "session",
  {
    id: text("id").primaryKey(),
    expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
    token: text("token").notNull().unique(),
    // ... createdAt, updatedAt, ipAddress, userAgent
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
  },
  (table) => [index("session_userId_idx").on(table.userId)],
);

Fíjate en la columna rol, que sale de additionalFields, y en que los identificadores son texto aleatorio, no enteros. Añade el fichero a drizzle.config.ts y genera la migración con drizzle-kit, como cualquier otra tabla:

// drizzle.config.ts
schema: ['./db/schema.ts', './db/auth-schema.ts'],
npx drizzle-kit generate --name better_auth
npx drizzle-kit migrate

drizzle/0002_better_auth.sql crea las cuatro tablas y sus cinco índices. La CLI también tiene un comando migrate, pero según la documentación de Better Auth solo sirve con su adaptador interno (Kysely); con Drizzle se usa generate y las migraciones las aplica drizzle-kit.

13.4. El Route Handler y los usuarios

Todas las rutas de Better Auth (/api/auth/sign-in/email, /api/auth/sign-out, /api/auth/get-session…) las sirve un único Route Handler catch-all:

// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/lib/auth'

// Todas las rutas de Better Auth: /api/auth/sign-in/email, /api/auth/get-session...
export const { GET, POST } = toNextJsHandler(auth)

Para crear los usuarios se usa la propia API de Better Auth, que cifra la contraseña ella misma:

// scripts/crear-usuarios-ba.ts
// Crea los usuarios de Better Auth con su propia API (cifra la contraseña él).
//   npx tsx --conditions=react-server --env-file=.env.local scripts/crear-usuarios-ba.ts
import { eq } from 'drizzle-orm'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { account, user } from '@/db/auth-schema'

const USUARIOS = [
  { name: 'Ana García', email: 'ana.garcia@empresa.es', password: 'clave-ana-2026', rol: 'ADMIN' },
  { name: 'Luis Martínez', email: 'luis.martinez@empresa.es', password: 'clave-luis-2026', rol: 'USER' },
]

async function crear() {
  await db.delete(user) // borra también sesiones y cuentas (on delete cascade)
  for (const { rol, ...datos } of USUARIOS) {
    const { user: creado } = await auth.api.signUpEmail({ body: datos })
    // input: false impide fijar el rol al registrarse: se cambia en la base de datos
    await db.update(user).set({ rol }).where(eq(user.id, creado.id))
    console.log(rol.padEnd(5), creado.email, creado.id)
  }
  const [cuenta] = await db.select({ password: account.password }).from(account).limit(1)
  console.log('Hash guardado en account.password:', cuenta.password?.slice(0, 50) + '...')
}

crear().catch((error) => {
  console.error(error)
  process.exitCode = 1
})
ADMIN ana.garcia@empresa.es WctFygN8ZjSjobSCTcr4Z30ImnlL9xA3
USER  luis.martinez@empresa.es lRHGKwUcHKDip5m2g5CwPoZfhWrxYa28
Hash guardado en account.password: eaa6528d9fc3efcdec06b553fd321089:1aef72d84f3fd0795...
  • --conditions=react-server es el truco para ejecutar fuera de Next un módulo que importa server-only: con esa condición, Node resuelve server-only a un módulo vacío en lugar del que lanza el error. --env-file carga .env.local (Better Auth necesita BETTER_AUTH_SECRET). Y tsx entiende el alias @/ porque lee los paths de tsconfig.json.
  • El hash va en account.password, no en user: un usuario puede tener varias cuentas (contraseña, Google, GitHub). El formato es sal:hash en hexadecimal, calculado con scrypt (el código de Better Auth indica que en Node usa node:crypto scrypt).

13.5. Iniciar sesión: con una Server Action o desde el cliente

La forma más parecida a la sesión propia es una Server Action que llama a auth.api.signInEmail. Gracias a nextCookies(), la cookie llega al navegador:

// app/ba/entrar/acciones.ts
'use server'

import { APIError } from 'better-auth/api'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'

export type EstadoEntrarBA = { error?: string }

export async function entrarBA(_previo: EstadoEntrarBA, formData: FormData): Promise<EstadoEntrarBA> {
  try {
    // nextCookies() hace que la cookie que crea Better Auth llegue al navegador
    await auth.api.signInEmail({
      body: { email: String(formData.get('email')), password: String(formData.get('clave')) },
      headers: await headers(), // IP y user-agent para la tabla session y el límite de intentos
    })
  } catch (error) {
    if (error instanceof APIError) {
      return { error: error.status === 'TOO_MANY_REQUESTS' ? 'Demasiados intentos: espera un poco' : 'Email o contraseña incorrectos' }
    }
    throw error
  }
  redirect('/ba/panel') // fuera del try
}
// app/ba/entrar/FormularioEntrarBA.tsx
'use client'

import { useActionState } from 'react'
import { entrarBA, type EstadoEntrarBA } from './acciones'

export function FormularioEntrarBA() {
  const [estado, accion, pendiente] = useActionState<EstadoEntrarBA, FormData>(entrarBA, {})
  return (
    <form action={accion} className="flex flex-col gap-3 bg-white p-6 rounded-lg shadow w-80">
      <label className="flex flex-col gap-1">
        Email
        <input name="email" type="email" required className="border rounded px-2 py-1" />
      </label>
      <label className="flex flex-col gap-1">
        Contraseña
        <input name="clave" type="password" required className="border rounded px-2 py-1" />
      </label>
      {estado.error && <p role="alert" className="text-red-700 text-sm">{estado.error}</p>}
      <button disabled={pendiente} className="bg-indigo-700 text-white rounded py-2 disabled:opacity-50">
        Entrar
      </button>
    </form>
  )
}
// app/ba/entrar/page.tsx
import { FormularioEntrarBA } from './FormularioEntrarBA'

export default function EntrarBetterAuth() {
  return (
    <main className="p-6 flex flex-col items-center gap-4">
      <h1 className="text-2xl font-bold">Entrar con Better Auth</h1>
      <FormularioEntrarBA />
    </main>
  )
}

La otra forma es el cliente de Better Auth, que llama a /api/auth/... con fetch desde el navegador:

// lib/auth-cliente.ts
import { createAuthClient } from 'better-auth/react'

// Sin baseURL: usa el mismo dominio que la página
export const authCliente = createAuthClient()
// app/ba/entrar-cliente/page.tsx (fragmento)
async function enviar(formData: FormData) {
  setPendiente(true)
  const { error } = await authCliente.signIn.email({
    email: String(formData.get('email')),
    password: String(formData.get('clave')),
  })
  setPendiente(false)
  if (error) {
    setError(error.status === 429 ? 'Demasiados intentos: espera unos segundos' : 'Email o contraseña incorrectos')
    return
  }
  router.refresh() // primero: descarta lo precargado cuando aún no había sesión
  router.push('/ba/panel')
}

⚠️ Importante: El orden de esas dos últimas líneas importa. Con router.push('/ba/panel') y después router.refresh(), la prueba con Playwright se quedaba en /ba/entrar aunque la petición de inicio de sesión respondía 200: el enrutador del cliente tenía precargada la ruta /ba/panel de cuando no había sesión (con la redirección del proxy incluida) y la reutilizó. Con refresh() antes de push(), llega al panel. La versión con Server Action no tiene este problema, porque una acción que escribe cookies invalida la caché del enrutador.

13.6. La DAL con Better Auth

La DAL tiene la misma forma que la propia; solo cambia de dónde sale la sesión:

// lib/dal/auth-ba.ts
import 'server-only'
import { cache } from 'react'
import { headers } from 'next/headers'
import { forbidden, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'

// La misma DAL de antes, pero la sesión la valida Better Auth (en la base de datos)
export const obtenerSesionBA = cache(async () => {
  return auth.api.getSession({ headers: await headers() })
})

export async function verificarSesionBA() {
  const sesion = await obtenerSesionBA()
  if (!sesion) redirect('/ba/entrar')
  return sesion.user
}

export async function exigirRolBA(rol: 'ADMIN' | 'USER') {
  const usuario = await verificarSesionBA()
  if (usuario.rol !== rol) forbidden()
  return usuario
}
// app/ba/panel/page.tsx
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { verificarSesionBA } from '@/lib/dal/auth-ba'

export default function PanelBetterAuth() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-4">Panel (Better Auth)</h1>
      <Suspense fallback={<p>Comprobando sesión...</p>}>
        <DatosSesion />
      </Suspense>
    </main>
  )
}

async function DatosSesion() {
  const usuario = await verificarSesionBA()
  return (
    <div className="bg-white shadow p-4 w-fit flex flex-col gap-2">
      <p>
        Hola, <strong>{usuario.name}</strong> ({usuario.email})
      </p>
      <p>
        Rol: <span className="bg-indigo-200 rounded px-2 text-xs">{usuario.rol}</span>
      </p>
      <form action={salirBA}>
        <button className="text-indigo-700 underline">Cerrar sesión</button>
      </form>
    </div>
  )
}

async function salirBA() {
  'use server'
  // Borra la fila de la tabla session y, gracias a nextCookies, también la cookie
  await auth.api.signOut({ headers: await headers() })
  redirect('/ba/entrar')
}

TypeScript sabe que usuario.rol existe gracias a additionalFields: typeof auth.$Infer.Session incluye los campos añadidos.

Panel de Better Auth con el saludo "Hola, Ana García (ana.garcia@empresa.es)", la etiqueta de rol ADMIN y un enlace para cerrar sesión

Figura 9. El panel con la sesión de Better Auth. Tras el inicio de sesión, Playwright vio la cookie better-auth.session_token httpOnly=true secure=false sameSite=Lax.

En el proxy (sección 10) la zona /ba/panel usa getSessionCookie(request) de better-auth/cookies, que solo comprueba que exista la cookie: no verifica su firma ni consulta la tabla. Es todavía más optimista que el proxy de la sesión propia, y por eso la DAL es imprescindible.

💡 Consejo: La cookie de Better Auth no llevaba Secure porque BETTER_AUTH_URL empezaba por http://. Según el código de better-auth/dist/cookies, con una URL https:// (o con advanced.useSecureCookies: true) usa cookies seguras y les añade el prefijo __Secure- al nombre. Es otra razón para que BETTER_AUTH_URL sea la URL pública real de producción.

13.7. Better Auth atacada con curl

== 1. Entrar con email y clave
HTTP/1.1 200 OK
set-cookie: better-auth.session_token=irjIV0YnXcJOdH1fESS6YW6CkJHKMXU8.xT9Ol%2FJnmJwCKM0seYg5KVq7KFtz75lGyLTH5sNTpYk%3D; Max-Age=28800; Path=/; HttpOnly; SameSite=Lax
== 2. get-session
{"session":{"expiresAt":"2026-11-02T22:11:05.364Z","token":"irjIV0YnXcJOdH1fESS6YW6CkJHKMXU8","createdAt":"2026-11-02T14:11:05.364Z","updatedAt":"2026-11-02T14:11:05.364Z","ipAddress":"127.0.0.1","userAgent":"curl/7.67.0","userId":"u39d45vhY0P98B3CvAOAHCIU7QWALlkf",...},"user":{"name":"Ana García",...,"rol":"ADMIN"}}
== 3. Panel con y sin cookie
200
Rol: ADMIN
HTTP/1.1 307 Temporary Redirect
location: /ba/entrar
== 4. Cookie inventada: el proxy la deja pasar, la DAL no
200
NEXT_REDIRECT;replace;/ba/entrar;307
0
== 5. Entrar desde otro origen
{"message":"Invalid origin","code":"INVALID_ORIGIN"} [403]
== 6. El rol no se puede elegir al registrarse
{"token":"kbiMrPzzvCdgxyuMRnPTtBapjzXO0lhF","user":{"name":"Intruso","email":"intruso@malo.example",...,"rol":"USER",...}} [200]
== 7. Cerrar sesion y reutilizar la cookie copiada
{"success":true} [200]
null [200]
== 8. Fuerza bruta
intento 1: 401
intento 2: 429
intento 3: 429
intento 4: 429
intento 5: 429
intento 6: 429

Lo que enseña cada prueba:

  1. La cookie es token.firma. La primera parte (irjIV0Yn...) es la llave de la fila de la tabla session, y coincide con el token que devuelve get-session; la segunda, una firma con BETTER_AUTH_SECRET que impide inventarse llaves. Lleva HttpOnly y SameSite=Lax por defecto.
  2. La sesión vive en la base de datos, con la IP y el navegador desde los que se abrió.
  3. El proxy redirige sin cookie.
  4. Una cookie inventada pasa el proxy (solo mira que exista) pero la DAL no encuentra la sesión y redirige dentro del stream (NEXT_REDIRECT), sin mostrar ningún dato (el 0 es el número de veces que aparece “Hola,”).
  5. Comprobación de origen: Better Auth rechaza con 403 las peticiones cuyo Origin no es BETTER_AUTH_URL (o uno de sus trustedOrigins).
  6. El registro está abierto por defecto. Cualquiera puede crearse una cuenta, aunque input: false impide que elija su rol: el campo "rol":"ADMIN" del cuerpo se ignoró y quedó USER. En una aplicación interna como EMPRESA, el registro debe cerrarse (ejercicio 6).
  7. Cerrar sesión revoca de verdad. Tras sign-out, la misma cookie copiada devuelve null: la fila de session ya no existe. Es la diferencia con la sesión propia de la sección 12.4, sin necesidad de inventar versionSesion.
  8. Límite de intentos: el segundo intento ya da 429 porque las pruebas anteriores habían consumido la ventana. En el código de Better Auth 1.7.15, las rutas que empiezan por /sign-in y /sign-up admiten 3 peticiones cada 10 segundos, y el límite solo está activo por defecto en producción (rateLimit.enabled ?? isProduction). Por defecto se guarda en memoria, así que con varias instancias del servidor necesitarías un almacenamiento compartido (la opción rateLimit.storage admite 'memory', 'database' y 'secondary-storage').

13.8. Sesión propia o Better Auth

Aspecto Sesión propia (jose) Better Auth
Código que escribes Contraseñas, sesión, DAL, formularios (unas 250 líneas en este módulo) Configuración, DAL y formularios
Dónde vive la sesión En el JWT (sin estado) En la tabla session (con estado)
Cerrar sesión de verdad Hay que añadir versionSesion Incluido
Comprobación en el proxy Firma y caducidad Solo que exista la cookie
Coste por petición en la DAL Una consulta por clave primaria Una consulta (o una caché de cookie, opción session.cookieCache)
Límite de intentos Tienes que programarlo (módulo 22) Incluido (en producción)
Registro, verificación de email, recuperación de contraseña, OAuth, 2FA Todo por hacer Opciones y plugins
Tablas Las tuyas, con tus nombres (usuarios) Las suyas (user, session, account, verification)
Encaje con Cache Components Igual: la sesión se lee fuera de 'use cache' Igual: getSession usa headers()

Para una aplicación real con usuarios externos, Better Auth (o un servicio) es la opción sensata: la lista de cosas que hay que hacer bien es larga, y una biblioteca mantenida ya las ha hecho. La sesión propia tiene sentido para aplicaciones internas pequeñas, para entender lo que pasa, o cuando necesitas un control que la biblioteca no da. En los dos casos, las reglas de este módulo no cambian: DAL, comprobación en cada punto de entrada, proxy solo optimista y la sesión fuera de 'use cache'.


14. Errores frecuentes

Error Síntoma Solución
Proteger solo en el proxy o en el layout Las páginas parecen protegidas, pero una acción o un handler responden a cualquiera Comprueba en cada página, acción y handler con la DAL (secciones 7 a 9)
Leer la sesión dentro de 'use cache' `cookies()` can't be read inside `"use cache"` en el build Lee la sesión fuera y pasa el identificador como argumento (sección 11.2)
await verificarSesion() arriba de un layout o página “Next.js encountered uncached or runtime data during prerendering” Mueve la lectura a un componente dentro de <Suspense> (sección 7.2)
Exportar la función en caché que recibe el empId Cualquier página puede leer datos de otro usuario Exporta solo la función que autoriza; la de caché, privada al módulo
Tomar el empId de la URL o del formulario Cambiando el número se ven datos ajenos (IDOR) Sácalo de la sesión, o comprueba la propiedad en la DAL
Usar el rol del token como verdad Un usuario degradado sigue siendo ADMIN hasta que caduca el token Lee el rol de la base de datos en la DAL
Mensajes distintos para “email no existe” y “contraseña mala” Se pueden enumerar los usuarios Un solo mensaje y el mismo tiempo de respuesta
redirect(desde) sin validar Redirección abierta a un sitio falso tras iniciar sesión destinoSeguro: solo rutas que empiezan por una /
redirect dentro de try La redirección no ocurre o se trata como error Llama a redirect después del try/catch
jwtVerify sin algorithms Se acepta cualquier algoritmo que diga el token algorithms: ['HS256']
SESSION_SECRET vacío o en el código Cualquiera puede firmar sesiones Variable de entorno de 32 bytes aleatorios, comprobada al arrancar
Esperar un 403 de forbidden() dentro de <Suspense> El estado es 200 (con noindex) Normal con streaming; si necesitas el código, compruébalo en el proxy
Registrar error.name de jose En producción salen nombres como np o nu Registra error.code (ERR_JWT_EXPIRED…)
Cerrar sesión sin estado y creer que la cookie robada deja de valer La copia sigue entrando versionSesion (o sesiones en base de datos)
npx auth generate con server-only en la configuración “Please remove import ‘server-only’ from your auth config file” Un auth.config.ts para la CLI con las opciones compartidas
router.push y luego router.refresh tras authCliente.signIn Se queda en la página de entrada refresh() antes de push(), o usa una Server Action
Registro de Better Auth abierto en una aplicación interna Cualquiera se crea una cuenta emailAndPassword.disableSignUp: true
secure: true sirviendo por HTTP con un nombre que no es localhost No se puede iniciar sesión: la cookie no se guarda HTTPS en producción
Pruebas con Git Bash y rutas que empiezan por / Los argumentos llegan cambiados export MSYS_NO_PATHCONV=1

15. Ejercicios

Ejercicio 1: El menú según el rol

En el layout de la sección 7.1, el enlace “Nóminas (ADMIN)” se muestra también a Luis, que al pulsarlo recibe la página 403. Haz que solo se pinte para ADMIN, y añade un enlace “Mi departamento” (lo usarás en el ejercicio 5). ¿Es esto una medida de seguridad?

Ver solución

Un componente de servidor lee el usuario (de la DAL, con cache, sin coste extra) y decide qué enlaces pinta:

// app/gestion/Navegacion.tsx
import Link from 'next/link'
import { obtenerUsuario } from '@/lib/dal/auth'

// Ejercicio 1: el enlace a Nóminas solo se pinta para ADMIN.
// Es comodidad, no seguridad: la página sigue comprobando el rol.
export async function Navegacion() {
  const usuario = await obtenerUsuario()
  return (
    <nav className="flex flex-col gap-1 mt-4">
      <Link href="/gestion">Inicio</Link>
      <Link href="/gestion/mi-ficha">Mi ficha</Link>
      <Link href="/gestion/companeros">Mi departamento</Link>
      {usuario?.rol === 'ADMIN' && <Link href="/gestion/nominas">Nóminas</Link>}
    </nav>
  )
}

En el layout, el <nav> fijo se sustituye por:

<Suspense fallback={null}>
  <Navegacion />
</Suspense>

(con import { Navegacion } from './Navegacion'; el import Link del layout ya no hace falta). Lee la sesión, así que va dentro de <Suspense>.

Comprobado contando los enlaces a /gestion/nominas en el HTML de /gestion:

== E1. Enlace a nominas en el menu
ADMIN: 1
USER:  0

No es una medida de seguridad: es experiencia de usuario. Luis puede seguir escribiendo /gestion/nominas en la barra de direcciones o llamar a la acción con curl. La protección sigue siendo exigirRol en la página y la comprobación en la acción. Si alguna vez quitas esas comprobaciones “porque el enlace ya no se ve”, has abierto un agujero.

Ejercicio 2: Un 403 de verdad desde el proxy

La sección 7.3 mostró que Luis recibe la página 403 con estado 200. Haz que el proxy responda con un 403 real a quien entre en /gestion/nominas sin ser ADMIN según su token. ¿Sustituye esto a exigirRol en la página?

Ver solución

En proxy.ts, después de la comprobación de sesión y antes de la de /entrar:

// Ejercicio 2: un 403 de verdad, antes de empezar a enviar la página
if (pathname.startsWith('/gestion/nominas') && sesion?.rol !== 'ADMIN') {
  return new NextResponse('403: solo administración', { status: 403 })
}

Resultado:

== E2. 403 desde el proxy
403: solo administración [403]
[200]

Luis recibe un 403 auténtico (útil para herramientas que miran el código de estado), y Ana sigue entrando (200).

No sustituye a exigirRol. El proxy usa el rol del token, que puede estar desactualizado: si a Ana le quitan el rol ADMIN, su token seguirá diciendo ADMIN hasta que caduque, y el proxy la dejaría pasar. La página, con exigirRol, mira la base de datos y la pararía. El proxy mejora la respuesta en el caso normal; la DAL decide.

Ejercicio 3: Iniciar sesión con JSON

Una aplicación móvil de EMPRESA necesita iniciar sesión sin formularios. Crea un Route Handler POST /api/sesion que reciba { "email", "clave" } en JSON y cree la cookie (respuesta 204), y DELETE /api/sesion para cerrarla. Protégelo contra CSRF.

Ver solución
// app/api/sesion/route.ts
// Ejercicio 3: iniciar y cerrar sesión con JSON (para una app móvil o un script)
import { z } from 'zod'
import { comprobarCredenciales } from '@/lib/dal/auth'
import { borrarSesion, crearSesion } from '@/lib/sesion'

const esquema = z.object({ email: z.email(), clave: z.string().min(1) })

// Un Route Handler no comprueba el origen como una Server Action: lo hacemos aquí
function origenPermitido(request: Request) {
  const origen = request.headers.get('origin')
  return origen === null || origen === new URL(request.url).origin
}

export async function POST(request: Request) {
  if (!origenPermitido(request)) return Response.json({ error: 'Origen no permitido' }, { status: 403 })

  const datos = esquema.safeParse(await request.json().catch(() => null))
  if (!datos.success) return Response.json({ error: 'Datos no válidos' }, { status: 400 })

  const usuario = await comprobarCredenciales(datos.data.email, datos.data.clave)
  if (!usuario) return Response.json({ error: 'Email o contraseña incorrectos' }, { status: 401 })

  await crearSesion(usuario)
  return new Response(null, { status: 204 })
}

export async function DELETE(request: Request) {
  if (!origenPermitido(request)) return Response.json({ error: 'Origen no permitido' }, { status: 403 })
  await borrarSesion()
  return new Response(null, { status: 204 })
}
== E3. /api/sesion
HTTP/1.1 204 No Content
set-cookie: sesion=eyJhbGciOiJIUzI1NiJ9.eyJ1c3VhcmlvSWQiOjIsInJvbCI6IlVTRVIiLCJ2ZXJzaW9uIj...
{"error":"Email o contraseña incorrectos"} [401]
{"error":"Origen no permitido"} [403]
HTTP/1.1 204 No Content
set-cookie: sesion=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
  • cookies().set() funciona igual en un Route Handler que en una acción, así que se reutilizan crearSesion y borrarSesion.
  • Comprobación de origen: un navegador siempre envía Origin en un POST hecho con fetch desde otra web. Si no coincide con el nuestro, 403. Las peticiones sin Origin (una app móvil, curl) se permiten porque no vienen de un navegador que pueda llevar cookies ajenas.
  • Este endpoint no pasa por el proxy (está en /api), y no le hace falta: no hay nada que proteger antes de iniciar sesión.

Ejercicio 4: Cambiar la contraseña

Escribe una Server Action cambiarMiClave(actual, nueva) que compruebe la contraseña actual, guarde la nueva cifrada y cierre las demás sesiones del usuario, pero no la del navegador desde el que se cambia.

Ver solución

En la DAL, una función que lo hace todo en una sola actualización y devuelve los datos de la sesión nueva:

// lib/dal/auth.ts (añadido)
// Ejercicio 4: cambia la contraseña y sube la versión (cierra las demás sesiones)
export async function cambiarClave(usuarioId: number, actual: string, nueva: string) {
  const [usuario] = await db
    .select({ hashClave: usuarios.hashClave })
    .from(usuarios)
    .where(eq(usuarios.usuarioId, usuarioId))
  if (!usuario || !(await compararClave(actual, usuario.hashClave))) return null

  const [fila] = await db
    .update(usuarios)
    .set({ hashClave: await cifrarClave(nueva), versionSesion: sql`${usuarios.versionSesion} + 1` })
    .where(eq(usuarios.usuarioId, usuarioId))
    .returning({ usuarioId: usuarios.usuarioId, rol: usuarios.rol, version: usuarios.versionSesion })
  return fila ?? null
}
// app/gestion/perfil/acciones.ts
'use server'

import { z } from 'zod'
import { cambiarClave, obtenerUsuario } from '@/lib/dal/auth'
import { crearSesion } from '@/lib/sesion'

export type Resultado = { ok: boolean; mensaje: string }

const esquema = z.object({
  actual: z.string().min(1),
  nueva: z.string().min(10, 'La nueva contraseña necesita 10 caracteres o más'),
})

export async function cambiarMiClave(actual: unknown, nueva: unknown): Promise<Resultado> {
  const usuario = await obtenerUsuario()
  if (!usuario) return { ok: false, mensaje: 'Tu sesión ha caducado: vuelve a entrar' }

  const datos = esquema.safeParse({ actual, nueva })
  if (!datos.success) return { ok: false, mensaje: datos.error.issues[0].message }

  const sesion = await cambiarClave(usuario.usuarioId, datos.data.actual, datos.data.nueva)
  if (!sesion) return { ok: false, mensaje: 'La contraseña actual no es correcta' }

  await crearSesion(sesion) // cookie nueva con la versión nueva: este navegador sigue dentro
  return { ok: true, mensaje: 'Contraseña cambiada. Las demás sesiones se han cerrado.' }
}

Un formulario cliente en app/gestion/perfil/ la llama con cambiarMiClave(formData.get('actual'), formData.get('nueva')) dentro de startTransition, igual que EditorSalario. Probado con Marta, guardando antes una copia de su cookie:

== E4. Cambiar la contrasena
{"ok":false,"mensaje":"La contraseña actual no es correcta"}
{"ok":false,"mensaje":"La nueva contraseña necesita 10 caracteres o más"}
{"ok":true,"mensaje":"Contraseña cambiada. Las demás sesiones se han cerrado."}
copia antigua: 401
cookie nueva:  200
entrar con la nueva: HTTP/1.1 303 See Other

La clave está en el orden: subir la versión invalida todos los tokens, incluido el actual; por eso la acción emite enseguida una cookie nueva con la versión nueva. Pedir la contraseña actual impide que alguien con la sesión abierta en un ordenador ajeno la cambie.

Ejercicio 5: Mi departamento, con caché compartida

Crea /gestion/companeros, que muestre los empleados del departamento del usuario. Los datos deben ir en caché, pero de modo que todos los empleados de VENTAS compartan la misma entrada.

Ver solución
// lib/dal/companeros.ts
import 'server-only'
import { asc, eq } from 'drizzle-orm'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
import { empleados } from '@/db/schema'
import { verificarSesion } from './auth'

// Ejercicio 5. La caché depende del departamento, no del usuario:
// todos los empleados de VENTAS comparten la misma entrada.
async function companerosEnCache(deptId: number) {
  'use cache'
  cacheTag('empleados', `departamento-${deptId}`)
  cacheLife('hours')
  console.log(`[db] companerosEnCache ${deptId}`)
  return db
    .select({ empId: empleados.empId, nombre: empleados.nombre, oficio: empleados.oficio })
    .from(empleados)
    .where(eq(empleados.deptId, deptId))
    .orderBy(asc(empleados.nombre))
}

export async function listarMisCompaneros() {
  const usuario = await verificarSesion()
  if (usuario.empId === null) return []
  const [yo] = await db
    .select({ deptId: empleados.deptId })
    .from(empleados)
    .where(eq(empleados.empId, usuario.empId))
  if (!yo?.deptId) return []
  return companerosEnCache(yo.deptId)
}
// app/gestion/companeros/page.tsx
import { Suspense } from 'react'
import { listarMisCompaneros } from '@/lib/dal/companeros'

export default function PaginaCompaneros() {
  return (
    <>
      <h1 className="text-2xl font-bold mb-4">Mi departamento</h1>
      <Suspense fallback={<p>Cargando...</p>}>
        <Lista />
      </Suspense>
    </>
  )
}

async function Lista() {
  const companeros = await listarMisCompaneros()
  if (companeros.length === 0) return <p>No hay nadie más en tu departamento.</p>
  return (
    <ul className="list-disc pl-6">
      {companeros.map((c) => (
        <li key={c.empId}>
          {c.nombre} ({c.oficio})
        </li>
      ))}
    </ul>
  )
}

Con la sesión de Luis (VENTAS, departamento 20), pedida dos veces:

== E5. Mi departamento
<li>Carmen
<li>David
<li>Elena
<li>Lucía
<li>Luis
<li>Sergio

y en el registro del servidor [db] companerosEnCache 20 aparece una sola vez. La clave de la caché es el deptId, que es de lo que dependen los datos: Carmen o David, de VENTAS también, recibirán la misma entrada. Si la clave fuera el usuarioId, habría una copia idéntica por cada empleado. El deptId se averigua fuera de la caché, a partir de la sesión: el usuario no puede pedir el departamento de otro.

Ejercicio 6: Better Auth para una aplicación interna

Cierra el registro público de Better Auth y crea /ba/admin, una página solo para ADMIN con la DAL de la sección 13.6. Comprueba con curl que nadie puede registrarse y que Luis no entra en la página. ¿Qué pasa con el script crear-usuarios-ba.ts?

Ver solución

En lib/auth-opciones.ts:

emailAndPassword: {
  enabled: true,
  minPasswordLength: 10,
  disableSignUp: true, // Ejercicio 6: nadie se registra solo
},
// app/ba/admin/page.tsx
import { Suspense } from 'react'
import { exigirRolBA } from '@/lib/dal/auth-ba'

// Ejercicio 6: página solo para ADMIN con Better Auth
export default function AdminBA() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-4">Administración (Better Auth)</h1>
      <Suspense fallback={<p>Comprobando permisos...</p>}>
        <Contenido />
      </Suspense>
    </main>
  )
}

async function Contenido() {
  const usuario = await exigirRolBA('ADMIN')
  return <p>Bienvenida, {usuario.name}. Aquí irían las herramientas de administración.</p>
}

Resultado:

== E6. Better Auth: registro cerrado y pagina de admin
{"message":"Email and password sign up is not enabled","code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"} [400]
Bienvenida, <!-- -->Ana García
403: no tienes permiso

(El <!-- --> es el separador que React mete en el HTML entre un texto fijo y una expresión.)

El registro devuelve 400, Ana entra y Luis ve la página de forbidden.tsx (con estado 200, por el mismo motivo que en la sección 7.3).

El script de creación de usuarios también falla: auth.api.signUpEmail respeta disableSignUp aunque se llame desde el servidor, y termina con code: 'EMAIL_PASSWORD_SIGN_UP_DISABLED'. Peor aún, el script ya había ejecutado db.delete(user), así que quedas sin usuarios. Dos lecciones: crea los usuarios iniciales antes de cerrar el registro (o desactiva la opción solo mientras ejecutas el script), y en un script que borra y crea, comprueba primero que la creación va a funcionar o hazlo todo en una transacción.

Ejercicio 7: ¿Por qué no comprobar versionSesion en el proxy?

En la sección 12.4, el proxy dejó pasar una cookie revocada. Un compañero propone consultar versionSesion en el proxy para cortar ahí también. Explica por qué no es buena idea y qué harías si de verdad necesitaras revocar en el proxy.

Ver solución
  • Coste: el proxy se ejecuta en cada petición que coincide con su matcher, incluidas las precargas de los <Link> visibles. Una consulta a la base de datos por cada una multiplica la carga sin necesidad, y es justo lo que la guía de Next desaconseja (“avoid database checks”).
  • No aporta seguridad: la DAL ya hace esa comprobación en cada página, acción y handler. Si la quitaras de la DAL “porque ya está en el proxy”, volverías a depender de una sola capa, con todos los problemas de la sección 10.1.
  • Lo que se gana es solo experiencia de usuario: con la cookie revocada, el usuario ve el armazón de la página un instante antes de que la DAL le redirija a /entrar.

Si de verdad necesitas revocar sesiones de forma inmediata en todas partes, la herramienta adecuada es una sesión con estado (como la de Better Auth), con una caché rápida delante (por ejemplo Redis) si el volumen lo exige. O acortar mucho la vida del token (minutos) y renovarlo a menudo, de modo que una copia robada caduque pronto.

Ejercicio 8 (propuesto): Límite de intentos en la sesión propia

Better Auth limita a 3 intentos cada 10 segundos. Añade algo parecido a la acción entrar: un contador en memoria por email e IP (la IP sale de headers(), cabecera x-forwarded-for) que, tras cinco fallos en un minuto, responda “Demasiados intentos” sin llegar a comprobar la contraseña. Pruébalo con un bucle de curl. ¿Qué pasa si reinicias el servidor, o si tienes dos instancias? El módulo 22 vuelve sobre este tema.

Ejercicio 9 (propuesto): El plugin admin de Better Auth

Sustituye el campo rol de additionalFields por el plugin admin de Better Auth (better-auth/plugins). Lee su documentación, genera de nuevo el esquema con la CLI y crea la migración con drizzle-kit. Usa sus funciones para listar usuarios, cambiar el rol de Luis y bloquear a un usuario, y comprueba que un usuario bloqueado no puede iniciar sesión.

Ejercicio 10 (propuesto): Todo en Next.js 15

Lleva la sesión propia de este módulo a un proyecto de Next.js 15: middleware.ts en lugar de proxy.ts (sección 10.2), la sesión leída arriba de las páginas sin <Suspense>, unstable_cache en lugar de 'use cache' (sección 11.4) y revalidateTag en lugar de updateTag. Compara la salida de next build con la de 16: ¿qué rutas son ƒ en 15 y ◐ en 16?


Resumen

Concepto API Idea clave
Autenticación y autorización 401 / 403 Quién eres frente a qué puedes hacer
Contraseñas scrypt, timingSafeEqual (node:crypto) Hash lento con sal; comparación en tiempo constante; nunca en claro
Sesión sin estado SignJWT, jwtVerify (jose) El JWT firmado lleva lo mínimo; firmar no es cifrar
Cookie de sesión cookies().set(nombre, token, { httpOnly, secure, sameSite, maxAge }) Invisible para JavaScript, solo por HTTPS, no viaja en POST de otros sitios
Inicio de sesión Server Action + useActionState Un único mensaje de error, mismo tiempo, redirect validado y fuera de try
DAL cache de React, server-only Un sitio que verifica la sesión, consulta la base de datos y devuelve un DTO
Proteger páginas verificarSesion, exigirRol, forbidden() Cada página se comprueba sola, dentro de <Suspense>; el layout no protege
Proteger acciones obtenerUsuario al principio Una acción es un endpoint público; los parámetros son unknown
Proteger handlers 401 / 403 / 404 en JSON La regla de autorización vive en la DAL
Proxy 🔄 16.0 proxy.ts, matcher, NextResponse.redirect Primera barrera optimista: sin base de datos y nunca la única
Cache Components 🆕 16.0 'use cache', cacheTag, 'use cache: private' Autorizar fuera, leer de la caché dentro; funciones en caché sin exportar
Revocación versionSesion Sin ella, una cookie robada vale hasta que caduca
Better Auth betterAuth, drizzleAdapter, nextCookies, toNextJsHandler Sesiones en base de datos, límite de intentos y origen incluidos
Esquema de Better Auth npx auth generate + drizzle-kit Configuración aparte para la CLI, sin server-only
Sesión en el servidor (Better Auth) auth.api.getSession({ headers }) La misma DAL con otra fuente; getSessionCookie en el proxy solo mira que exista
Auth.js v5 next-auth@beta Sigue en beta y su proyecto es ahora parte de Better Auth