Autorización y sesión en Next.js
Documento sobre el frontend que recibe la sesión del backend (tokens o
id de sesión) y debe almacenarla, renovarla y verificarla. Si el proyecto
utiliza una librería o proveedor (Auth.js, Better Auth, Clerk, etc.),
la mecánica de la sesión la asume, pero las reglas de las secciones 2, 6–10
siguen vigentes.
Código — references/implementation.md.
Prioridad de reglas. Las instrucciones del usuario y los archivos agent del proyecto son más importantes. Si el backend dicta otra cosa (por ejemplo, él mismo coloca la cookie), sigue al backend y anota la diferencia en el README o ADR.
Notaciones: MUST — no se puede violar; SHOULD — por defecto sí, la excepción requiere motivo.
1. Elección del modelo
| Modelo | Cuándo |
|---|---|
| BFF: los route handlers de Next reciben los tokens del backend y los colocan en HttpOnly-cookie | El backend entrega tokens en el cuerpo de la respuesta. Opción por defecto para Next |
| El propio backend coloca la HttpOnly-cookie | Frontend y API en el mismo sitio, el backend está bajo tu control |
| Librería o proveedor | Se necesitan OAuth, MFA, gestión de usuarios — no lo implementes tú mismo |
| Access en memoria, refresh en HttpOnly-cookie | SPA sin BFF |
Tokens en localStorage/sessionStorage | Prohibido: cualquier XSS se lleva la sesión |
Segunda bifurcación — qué proxea el BFF:
| Opción | Ventajas | Desventajas |
|---|---|---|
| Solo login / refresh / logout, las demás solicitudes van directamente a la API con la cookie | BFF no es cuello de botella; menos código | La API lee el token de la cookie y configura CORS; las cookies deben llegar al dominio de la API |
| Todas las solicitudes a través del BFF | La API no sabe de cookies ni CORS; un solo origen | Cada solicitud es un salto extra; el BFF debe escalarse |
La elección se registra en un ADR.
2. Cookie (MUST)
HttpOnlypara todo lo que concede acceso (access, refresh, id de sesión).Secureen producción. Localmente los navegadores permitenSecureenhttp://localhost, pero no en otros hosts http.SameSite=Laxpor defecto.Strictes más seguro contra CSRF, pero un usuario que llega por un enlace de un correo o desde otro sitio, en la primera transición parece no autenticado — elígelo conscientemente y anota el motivo.Path=/; tiempo de vida — delexpdel token o de la respuesta del backend, no «a ojo».- SHOULD: prefijo
__Host-en el nombre (requiereSecure,Path=/, sinDomain) — la cookie no puede ser sustituida desde un subdominio. - Las cookies se colocan y se eliminan solo en el servidor (route handler, Server
Action,
proxy.ts). Los Server Components no pueden colocar cookies. - La cookie no legible desde JS es el único portador de la sesión. Una bandera del tipo
is_authenticatedsinHttpOnlysolo está permitida como indicación para la UI y nunca se usa para decisiones de acceso.
3. BFF: route handlers (MUST)
- El inicio de sesión se valida con un esquema (
zod); cuerpo con forma incorrecta —400. - Los handlers mutantes verifican
Origincontra el origen de la aplicación:SameSite=Laxno protege contra solicitudes desde subdominios del mismo sitio. Las Server Actions comparanOriginconHostpor sí mismas. - El contexto del cliente se propaga al backend:
X-Forwarded-For,User-Agent— de lo contrario, el rate limit del backend solo ve la IP del servidor. - Los errores del backend se traducen a una respuesta con
kindy estado (401,403,422,429conRetry-After,503), el texto del backend no sale al exterior. - Refresh: si el backend rechazó el refresh token — eliminar la cookie y
responder
401. Si el backend no está disponible (red, 5xx) — no tocar la cookie, responder503: un fallo temporal no debe cerrar la sesión. - Logout: revocar el token en el backend (el error se registra, pero no se muestra), la cookie se elimina en cualquier caso.
4. Dónde verificar la sesión (MUST)
| Lugar | Qué hace | Qué no hace |
|---|---|---|
proxy.ts (Next 16+, antes middleware.ts) | Verificación optimista por presencia de cookie: redirigir al login y desde páginas de login | No accede a la BD ni a la API; no es la única protección |
Data Access Layer (verifySession() en módulo server-only) | Verificación junto a los datos: en Server Components, route handlers, Server Actions | — |
| Layout | Nada: los layouts no se redibujan con la navegación | No verifica acceso |
| Backend | Fuente de verdad: verifica el token en cada solicitud | — |
- Cada route handler y Server Action es un punto de entrada público y por sí mismo
llama a
verifySession(). proxy.tsverifica la refresh-cookie (o id de sesión): el access pudo haber expirado, pero la sesión sigue viva.- Coincidencia de ruta — por segmentos:
/appy/app/…, pero no/apple. - Con
cacheComponents(por defecto en proyectos con Next 16.4), la lectura de la sesión y el almacenamiento en caché de los datos del usuario tienen sus propias reglas: antes de escribir un DAL, leenode_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md. - Los permisos (rol, propiedad) se deciden según datos verificados: por firma (JWKS,
jose) o por la respuesta del backend. Un payload JWT no verificado no es base para el acceso. - Los Server Components no pueden actualizar la cookie. Si el access caduca durante el
renderizado del servidor: o bien
proxy.tsrenueva la sesión (puede colocar cookie en la respuesta — hazlo solo para navegaciones de documento, no para prefetch, y ten en cuenta la rotación de refresh tokens), o la página renderiza una carga cliente y el refresh lo hace el cliente.
5. Cliente: refresh sin condiciones de carrera (MUST)
- Definición de «no autorizado»: HTTP
401, o en la respuesta GraphQL (es200) al menos un error conextensions.code === "UNAUTHENTICATED"(errors.some, noerrors[0]). La comparación con el texto del mensaje solo si el backend no entrega códigos, en una única función, con comentario y test. - Single-flight: las solicitudes paralelas que reciben 401 esperan un único refresh
común y usan su resultado. La promesa se limpia en
finally. - La solicitud original se repite una vez. Un segundo 401 — sesión perdida, sin segundo refresh.
- La propia solicitud de refresh no pasa por el interceptor (si no, recursión).
- El resultado del refresh distingue tres casos: concedido (
true) → repetir; rechazado (false) → sesión caducada; red o 5xx (excepción) → errornetwork, sin cerrar sesión. - Durante el logout, el refresh está bloqueado: de lo contrario, una solicitud que cae en 401 en el momento del logout restablecería la sesión inmediatamente.
- Sesión caducada: limpiar caché de solicitudes, redirigir al login con
callbackUrlde la página actual. - El cuerpo de la solicitud que se repite debe ser repetible (string,
FormData), no unReadableStreamde un solo uso.
6. Redirección después del login (MUST)
callbackUrl(next,returnTo) se verifica con la funcióngetSafeRedirect(raw, fallback, appOrigin): rechaza\y caracteres de control (los navegadores convierten/\evil.comy/<tab>/evil.comen//evil.com), parseanew URL(raw, appOrigin), compara el origen y devuelve solo la ruta. La verificación «empieza con/y no con//» es insuficiente.- El conjunto de tests para ella —
references/implementation.md, sección 7. - Después del login, los Server Components deben ver las nuevas cookies: navegación
completa (
window.location.assign) orouter.replace+router.refresh().
7. Cierre de sesión (MUST)
- El servidor elimina la cookie (sección 3).
- El cliente limpia la caché de solicitudes (
queryClient.clear()) y todos los stores del cliente con datos del usuario: el siguiente usuario en la misma pestaña no debe ver datos ajenos ni siquiera por un instante. - Navegación a la página de login sin posibilidad de volver «atrás» a una pantalla
protegida en caché (
replaceo navegación completa). - Confirmación de cierre de sesión — opcional según el producto; bloqueo del botón durante la solicitud — obligatorio.
- SHOULD: otras pestañas se enteran del cierre de sesión (
BroadcastChannelo eventostoragea través de una bandera), si no, siguen mostrando datos hasta el primer 401.
8. Tokens en enlaces (restablecimiento de contraseña, confirmación de email)
- El token se lee de la URL una vez en un estado, luego la dirección se reemplaza
sin el token (
router.replace(pathname)): no queda en el historial y no se envía enReferer. - Para estas páginas —
Referrer-Policy: no-referrery ningún script de terceros (analítica, widgets). - El token no se registra en logs. La caducidad y el uso único los garantiza el backend; el frontend muestra una pantalla clara de «enlace caducado» con la acción «solicitar uno nuevo».
9. Formularios de inicio de sesión y recuperación
- La recuperación de contraseña responde igual para un email existente y uno inexistente («si la cuenta existe, se ha enviado un correo»), sin revelar quién está registrado. Si el backend revela información — anotarlo como limitación.
- El envío se bloquea durante la solicitud; el reenvío no crea una segunda solicitud.
429: mensaje con tiempo de espera deRetry-After, si está presente.- Campos:
type="email"+autoComplete="email"; contraseña —autoComplete="current-password"en inicio de sesión y"new-password"en registro y cambio; no se bloquea la inserción. - Botón «mostrar contraseña» —
type="button",aria-labelyaria-pressed, no quita el foco del campo. - El error «email o contraseña incorrectos» no especifica qué es incorrecto.
- Los errores del servidor se asignan a los campos según el código; el error general — sobre el formulario.
10. Datos y logs
- Las respuestas que dependen del usuario no entran en cachés compartidos
(
"use cache", CDN). QueryClient en el servidor — nuevo por cada solicitud. - En los logs no hay tokens, contraseñas, emails. Como máximo — id de usuario y
kinddel error. - El payload del token de sesión es mínimo: id y rol, sin datos personales.
11. Tests
- Unitario:
getSafeRedirect(todo el conjunto de references); definición de «no autorizado» (401, códigos GraphQL, cuerpo vacío, HTML en lugar de JSON). - Single-flight: N solicitudes paralelas con 401 → un refresh, N
repeticiones; refresh rechazado → ninguna repetición, una llamada a
onSessionExpired; refresh falla por red → errornetwork, sin cerrar sesión; segundo 401 tras refresh exitoso → sin segundo refresh; durante logout → sin refresh. - Route handlers: flags de cookie; refresh rechazado → cookie eliminada; backend
no disponible → cookie intacta,
503; cuerpo inválido →400;Originajeno →403. - e2e: inicio de sesión → página protegida → cierre de sesión → página protegida no accesible; expiración de access (TTL corto en entorno de pruebas) → refresh transparente; enlace de restablecimiento de contraseña → token desaparece de la barra de direcciones.
12. Definition of Done
- Ningún token se lee desde JS; no se usa
localStoragepara la sesión. - Cookie:
HttpOnly,Secureen prod,SameSite,Path, tiempo de vida del token. - Refresh: se distingue entre rechazo e indisponibilidad del backend.
- Single-flight con resultado, una repetición, bloqueo durante logout — cubierto con tests.
-
proxy.ts— solo verificación optimista; DAL se llama en handlers, actions y Server Components. -
getSafeRedirectcon el conjunto completo de tests. - El cierre de sesión limpia la caché y los stores del cliente.
- Los tokens de los enlaces se eliminan de la barra de direcciones.
- En los logs no hay tokens ni datos personales.
- La elección del modelo y del proxy está registrada en un ADR.