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 cookiehttpOnly, 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
cachede React yserver-only - Proteger páginas, Server Actions y Route Handlers, cada uno por su cuenta, con roles ADMIN y USER
- Usar
proxy.tscomo 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 buildy ejecutado connext starten Next.js 16.4.3, conjose6.2.15,better-auth1.7.15,drizzle-orm0.45.4 y@libsql/client0.18.0 sobre Node.js 24. Las salidas que ves (cabecerasSet-Cookie, respuestas decurl, 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.zipy la capalib/db. Le añade una tablausuarios(sección 3) y, para Better Auth, otras cuatro (sección 13). Si no has hecho el módulo 16, descomprime el recurso y ejecutanpm run db:prepararantes 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:
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.
- 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. - Cada punto de entrada (página, Server Action, Route Handler) pide a la DAL el usuario actual y decide.
- 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.localya está en el.gitignoreque generacreate-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_URLes 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:
usuarioses una tabla aparte deempleados. No todo usuario es un empleado (un gestor externo, una cuenta de servicio) ni todo empleado tiene acceso a la aplicación.empIdenlaza los dos cuando corresponde, y admite nulos.hashClave, noclave. El nombre recuerda a quien lea el esquema que ahí nunca hay una contraseña.rolconenumyCHECK. Elenumde Drizzle solo actúa en TypeScript; elCHECKhace que la propia base de datos rechace un rol inventado, aunque alguien escriba SQL a mano.versionSesionsirve 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 costeN, los hashes antiguos se siguen pudiendo comprobar con los parámetros con que se crearon. scryptcon callback, envuelto en una promesa. ExistescryptSync, 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.timingSafeEqualcompara 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 rellenaempleados. Comousuarios.emp_idtieneON DELETE set null, después de recargar los empleados los usuarios quedan sin empleado asociado. Ejecutadb:usuariosdespués dedb: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.
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,
EncryptJWTenjose), 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 conundefinedy aceptar tokens firmados con una clave vacía. algorithms: ['HS256']enjwtVerify. Es la defensa contra el ataque clásico de cambiar la cabecera a"alg": "none"(token sin firma).joseya rechazanonepor defecto, pero fijar la lista es la buena práctica: el servidor decide el algoritmo, no el token.descifrarSesioncomprueba 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).descifrarSesionno lanza errores: devuelvenull. 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”.descifrarSesionno 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).
4.3. Los atributos de la cookie
| 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: trueconnext starten tu ordenador funciona porque los navegadores tratanlocalhosty127.0.0.1como 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
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:
- 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.
-
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 concurlen 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,145156Las diferencias son ruido: no hay un patrón que delate qué email existe.
destinoSeguro. El proxy envía a/entrar?desde=/gestion/nominaspara volver allí tras iniciar sesión. Si se redirigiera a cualquier valor dedesde, un atacante podría enviar a alguien un enlace atu-web/entrar?desde=https://web-falsa.exampley, 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.examplees una URL de otro sitio para el navegador, aunque empiece por/.redirectfuera detry.redirectfunciona lanzando una excepción especial; dentro de untry/catchse la tragaría elcatch(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.searchParamsdentro 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 sindesde, así que el usuario ve el formulario al instante.

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 unPOSTnormal del navegador la ejecuta y recibe el303con la cookie. Así es como se ha probado concurlen 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.
obtenerUsuarioseleccionaversionSesionpara comprobarla, pero no la devuelve; y nunca seleccionahashClave. 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,
verificarSesionse llama dos veces (menú y tabla), pero la consulta (obtenerUsuario) se ejecuta una:cachefunciona. - 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, ycachede React solo vale dentro de un render. Lo mismo se vio en/gestion/privado, donde solo el layout pide el usuario: dos líneasobtenerUsuariopor petición. Por eso sigue habiendo dos consultas por petición en vez de cuatro. Fíjate en quenominasEnCache(con'use cache', sección 11) solo aparece una vez: la segunda pasada ya la encuentra en la caché.
⚠️ Importante:
cachede 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 confundascache(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?
- El rol puede haber cambiado desde que se emitió el token.
- El usuario puede haberse borrado o desactivado.
- 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 escribirconst 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>
)
}

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

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).

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, nonumber. TypeScript no protege nada en tiempo de ejecución: quien llama concurlpuede enviar cualquier cosa. Zod convierteunknownen 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íarevalidateTag('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.tsy la funciónmiddleware; por defecto se ejecuta en el runtime Edge. Si usas Next.js 15: creamiddleware.tsconexport async function middleware(request: NextRequest)y el mismo código;josefunciona en Edge. ImportajwtVerifydejosedirectamente en lugar delib/sesion.ts, que usanext/headersyserver-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
/entrarcon un307, 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:
- 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. - 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.
- 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 connext 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,cacheLifey'use cache: private'no existen en Next.js 15. Si usas Next.js 15: lee la sesión concookies()donde quieras (la ruta pasa a ser dinámica,ƒ) y guarda en caché los datos por usuario conunstable_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:
- La comprobación nunca va en caché; los datos sí.
listarNominascomprueba el rol en cada petición (a través deexigirRol) y solo después pide los datos anominasEnCache, 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. - Las funciones en caché no se exportan. Si
fichaEnCachefuera pública, cualquier página podría llamarla con elempIdde otro saltándosepuedeVerNomina. La guía de Next lo recomienda expresamente. - La clave de la caché es lo que determina los datos.
fichaEnCache(101)yfichaEnCache(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. - 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="{"id":"[0-9a-f]*' | head -1 | sed 's/.*"//')
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-fichallega al servidor como una ruta de disco y//malo.examplecomo/malo.example. En las primeras pruebas de este módulo eso hizo creer quedestinoSegurofallaba, 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
descifrarSesionregistrabaerror.nameen lugar deerror.code, y en producción salían cosas comotoken rechazado: npotoken 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 dejosey 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 delmatcher, 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.
12.4. Cookie robada y cierre de sesión
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:
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.salirDeTodo()subeversionSesionen la base de datos. El token robado llevaversion: 3, la tabla ya dice 4, y la DAL lo rechaza: 401.- 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
/entrary no se muestra ningún dato. Es la diferencia entre comprobación optimista y comprobación de verdad.
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/dbdel módulo 16) y las tablas que le pases.additionalFields.rol: añade la columnarola la tabla de usuarios de Better Auth. Coninput: false, nadie puede elegir su rol al registrarse: lo comprobarás en la sección 13.6. Better Auth tiene también un pluginadmincon 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 concookies()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-serveres el truco para ejecutar fuera de Next un módulo que importaserver-only: con esa condición, Node resuelveserver-onlya un módulo vacío en lugar del que lanza el error.--env-filecarga.env.local(Better Auth necesitaBETTER_AUTH_SECRET). Ytsxentiende el alias@/porque lee lospathsdetsconfig.json.- El hash va en
account.password, no enuser: un usuario puede tener varias cuentas (contraseña, Google, GitHub). El formato essal:hashen hexadecimal, calculado con scrypt (el código de Better Auth indica que en Node usanode: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ésrouter.refresh(), la prueba con Playwright se quedaba en/ba/entraraunque la petición de inicio de sesión respondía 200: el enrutador del cliente tenía precargada la ruta/ba/panelde cuando no había sesión (con la redirección del proxy incluida) y la reutilizó. Conrefresh()antes depush(), 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.

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
SecureporqueBETTER_AUTH_URLempezaba porhttp://. Según el código debetter-auth/dist/cookies, con una URLhttps://(o conadvanced.useSecureCookies: true) usa cookies seguras y les añade el prefijo__Secure-al nombre. Es otra razón para queBETTER_AUTH_URLsea 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:
- La cookie es
token.firma. La primera parte (irjIV0Yn...) es la llave de la fila de la tablasession, y coincide con eltokenque devuelveget-session; la segunda, una firma conBETTER_AUTH_SECRETque impide inventarse llaves. LlevaHttpOnlyySameSite=Laxpor defecto. - La sesión vive en la base de datos, con la IP y el navegador desde los que se abrió.
- El proxy redirige sin cookie.
- 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 (el0es el número de veces que aparece “Hola,”). - Comprobación de origen: Better Auth rechaza con 403 las peticiones cuyo
Originno esBETTER_AUTH_URL(o uno de sustrustedOrigins). - El registro está abierto por defecto. Cualquiera puede crearse una cuenta, aunque
input: falseimpide 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). - Cerrar sesión revoca de verdad. Tras
sign-out, la misma cookie copiada devuelvenull: la fila desessionya no existe. Es la diferencia con la sesión propia de la sección 12.4, sin necesidad de inventarversionSesion. - 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-iny/sign-upadmiten 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ónrateLimit.storageadmite'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 reutilizancrearSesionyborrarSesion.- Comprobación de origen: un navegador siempre envía
Originen unPOSThecho confetchdesde otra web. Si no coincide con el nuestro, 403. Las peticiones sinOrigin(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 |