Next.js 16 Middleware: patrones de autorización que escalan y los que generan race conditions
El middleware de Next.js es básicamente como el portero de un boliche. No decide si sos bienvenido adentro — eso lo hace el staff interno. Pero sí decide si te deja pasar la puerta. Y si el portero empieza a revisar el historial completo de cada persona antes de abrir, la fila llega hasta la otra cuadra.
Ese es exactamente el problema con los patrones de autorización en Next.js 16 Middleware. La mayoría de los ejemplos que circulan online asumen que podés hacer validación completa de tokens en el edge. La realidad es más incómoda: el edge runtime tiene restricciones concretas, y varios patterns que funcionaban en v14 explotan en producción de maneras que no son obvias.
Mi tesis: el middleware de Next.js 16 es potente, pero su fortaleza está en verificar sesión, no en validar token completo. Cuando confundís los dos roles, generás race conditions o latencia que no entendés hasta que estás mirando logs a las 11 de la noche.
El problema real: edge runtime no es Node.js
Antes de ver cada patrón, hay un hecho que define todo lo que sigue: el middleware de Next.js corre en edge runtime, no en Node.js completo. Eso no es un detalle menor — es la razón por la que ciertos patterns fallan.
El edge runtime tiene acceso a APIs web estándar (Request, Response, Headers, crypto.subtle) pero no tiene acceso a:
fs— nada de leer archivos- módulos nativos de Node.js
- librerías que dependan de buffers de Node o APIs de sistema
Lo que esto significa para auth es concreto: si tu librería de JWT usa jsonwebtoken con crypto de Node, no va a funcionar en middleware. Necesitás usar jose u otra librería compatible con Web Crypto API.
// ❌ Esto explota en edge runtime
import jwt from 'jsonwebtoken' // depende de crypto de Node
// ✅ Esto funciona en edge runtime
import { jwtVerify } from 'jose' // Web Crypto API compatibleLa documentación oficial de Next.js Middleware lo menciona, pero entre tanto ejemplo de código uno tiende a saltear esa parte hasta que el error aparece en el deploy.
Los 4 patrones: análisis de tradeoffs
Patrón 1 — Validación de token completo en middleware
El más tentador y el más problemático.
La idea: agarrás el token de la cookie o el header Authorization, lo verificás criptográficamente en el middleware y decidís si el usuario pasa.
// middleware.ts
import { jwtVerify } from 'jose'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
// Verificación criptográfica completa en cada request
const { payload } = await jwtVerify(token, SECRET)
// Pasamos el userId al header para usarlo downstream
const response = NextResponse.next()
response.headers.set('x-user-id', payload.sub as string)
return response
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}El tradeoff honesto: jwtVerify con jose sí corre en edge runtime. La verificación criptográfica en sí es rápida. El problema aparece cuando el token tiene expiración corta y necesitás también consultar una lista de revocación, o cuando querés verificar permisos granulares que están en base de datos. Ahí estás en problemas, porque hacer un fetch a base de datos desde middleware en cada request es latencia que se acumula.
Cuándo funciona bien: tokens de vida larga, sin revocación activa, donde solo necesitás saber si el token es válido estructuralmente.
Cuándo explota: si tu sistema revoca tokens (logout real, cambio de contraseña), este patrón no lo refleja hasta que el token expira.
Patrón 2 — Role-based redirects en middleware
Este patrón parece simple pero esconde una race condition específica de Next.js App Router.
// middleware.ts — patrón de redirects por rol
export async function middleware(request: NextRequest) {
const sessionCookie = request.cookies.get('session')?.value
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Decodificamos sin verificar — solo para leer el rol del payload
// ⚠️ IMPORTANTE: esto NO es verificación de seguridad
const parts = sessionCookie.split('.')
if (parts.length !== 3) {
return NextResponse.redirect(new URL('/login', request.url))
}
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString()
)
const { pathname } = request.nextUrl
// Redireccionamos según rol
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/403', request.url))
}
return NextResponse.next()
}El problema de race condition: si usás NextResponse.redirect en middleware al mismo tiempo que el cliente tiene un Server Component haciendo fetch desde layout.tsx, podés terminar con dos requests en vuelo apuntando a destinos distintos. El App Router tiene su propio mecanismo de navegación y el redirect del middleware interrumpe el ciclo de hidratación de manera que no siempre es predecible.
El síntoma: el usuario ve un flash de contenido antes del redirect, o queda en un loop de redirect en ciertas rutas. Reproducible cuando el matcher cubre rutas con layouts anidados que hacen fetch propio.
La corrección: usá NextResponse.rewrite en lugar de redirect para rutas de API o internas, y reservá redirect solo para el caso de "no hay sesión en absoluto". Para permisos granulares dentro de una sesión válida, delegá la decisión al Server Component o al Route Handler — que sí tienen acceso completo a base de datos.
Patrón 3 — API route protection solo en middleware
Este es el patrón que más veo recomendado en tutoriales y el que tiene el costo oculto más caro.
La idea es usar el matcher para proteger todas las rutas /api/ desde middleware y no validar nada dentro del route handler.
// middleware.ts — protección de API desde middleware únicamente
export const config = {
matcher: ['/api/:path*'],
}
export async function middleware(request: NextRequest) {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
if (!token) {
return new NextResponse(
JSON.stringify({ error: 'No autorizado' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
}
// Verificamos token y pasamos
try {
await jwtVerify(token, SECRET)
return NextResponse.next()
} catch {
return new NextResponse(
JSON.stringify({ error: 'Token inválido' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
)
}
}El problema: este patrón asume que middleware es la única capa de seguridad. Si alguna vez llamás directamente a un route handler internamente (Server Action, fetch server-side, otro route handler), el middleware no interviene. Ese bypass silencioso es el vector de seguridad que más cuesta descubrir.
El costo real: middleware como único guardián funciona si toda la superficie de acceso pasa por la misma puerta. En App Router, con Server Actions y llamadas server-side, esa suposición no siempre se cumple.
Mi criterio: middleware protege el perímetro. Los route handlers validan autorización propia. Las dos capas tienen que existir, no es una o la otra. Si esto te parece redundante, es redundancia que vale la pena.
Patrón 4 — Composición de middlewares
Next.js 16 no tiene middleware anidado nativo — hay un solo archivo middleware.ts. Para componer lógica, el patrón común es encadenar funciones manualmente.
// middleware.ts — composición manual
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Cada función devuelve NextResponse o null (para continuar la cadena)
type MiddlewareFn = (req: NextRequest) => NextResponse | null | Promise<NextResponse | null>
// Verifica que exista sesión
function withSession(req: NextRequest): NextResponse | null {
const session = req.cookies.get('session')?.value
if (!session) {
return NextResponse.redirect(new URL('/login', req.url))
}
return null // continúa
}
// Bloquea rutas de admin para no-admins
function withAdminGuard(req: NextRequest): NextResponse | null {
if (!req.nextUrl.pathname.startsWith('/admin')) return null
const session = req.cookies.get('session')?.value
if (!session) return null // withSession ya manejó esto
const parts = session.split('.')
if (parts.length !== 3) return null
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
if (payload.role !== 'admin') {
return NextResponse.redirect(new URL('/403', req.url))
}
return null
}
// Función de composición
function compose(...fns: MiddlewareFn[]) {
return async (req: NextRequest): Promise<NextResponse> => {
for (const fn of fns) {
const result = await fn(req)
if (result) return result // cortocircuito al primer resultado
}
return NextResponse.next()
}
}
export const middleware = compose(withSession, withAdminGuard)
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}El tradeoff de este patrón: es limpio y escalable, pero tiene un costo de mantenimiento. Cada función del pipeline decodifica el token de manera independiente — si tenés 4 guards que todos leen el mismo cookie, estás parseando el JWT 4 veces por request.
La optimización concreta: parsear el token una sola vez al principio y pasar el payload como contexto a través de headers o de un objeto de request aumentado. Pero Next.js no tiene un mecanismo de contexto nativo entre funciones de middleware, así que el tradeoff es parsear múltiples veces vs. acoplar el parsing al inicio del pipeline.
Los gotchas que nadie documenta bien
Buffer.from en edge runtime: en algunos deployments de edge (Vercel Edge, Cloudflare Workers), Buffer no está disponible globalmente. Si decodificás JWT con Buffer.from(..., 'base64url'), tu middleware puede funcionar local y explotar en producción. La alternativa portable:
// Decodificación de base64url portable para edge runtime
function decodeJWTPayload(token: string): Record<string, unknown> {
const base64 = token.split('.')[1]
.replace(/-/g, '+')
.replace(/_/g, '/')
const json = atob(base64) // atob está disponible en Web APIs
return JSON.parse(json)
}El matcher y las rutas estáticas: el middleware corre en cada request que matchea, incluyendo assets estáticos si el matcher no está bien definido. Un matcher mal escrito puede ejecutar lógica de auth en archivos .ico, .png y fuentes. Esto no es un bug, es un costo silencioso de CPU en edge.
// matcher recomendado: excluye assets explícitamente
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.png|.*\\.svg).*)',
],
}Race condition con cookies de sesión nueva: si el middleware hace redirect y al mismo tiempo el cliente intenta escribir una cookie de sesión nueva (ej: después de login), el redirect puede limpiar la cookie antes de que se persista. Reproducible en flows de login con redirect inmediato sin esperar a que la cookie se confirme en el cliente.
El patrón que adoptaría en un sistema nuevo
Después de analizar los cuatro, el que mejor balancea seguridad, performance y mantenibilidad es una versión híbrida:
- Middleware: verifica existencia de sesión (¿hay token? ¿tiene forma de JWT?) y redirige si no hay nada. Sin verificación criptográfica completa en el middleware si hay revocación activa.
- Server Components / Route Handlers: verifican el token completo con
josey consultan permisos granulares si los necesitan. - Matcher restrictivo: solo rutas de app, nunca assets estáticos.
// middleware.ts — el pattern que adoptaría hoy
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Rutas públicas que no necesitan sesión
const PUBLIC_PATHS = ['/', '/login', '/register', '/api/auth']
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(path =>
pathname === path || pathname.startsWith(path + '/')
)
}
function hasSessionShape(token: string): boolean {
// Verificación de forma solamente, no criptográfica
// La verificación real va en el route handler o server component
const parts = token.split('.')
return parts.length === 3 && parts.every(p => p.length > 0)
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Rutas públicas: siempre pasan
if (isPublicPath(pathname)) {
return NextResponse.next()
}
const sessionToken = request.cookies.get('session')?.value
// Sin token: redirigir a login
if (!sessionToken || !hasSessionShape(sessionToken)) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// Con token con forma válida: dejamos pasar
// La verificación criptográfica y de permisos va downstream
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(png|svg|jpg|ico)).*)',
],
}Este patrón es deliberadamente conservador: el middleware hace solo lo que puede hacer bien en edge runtime (verificar existencia y forma del token), y delega la autorización real a capas que tienen acceso completo a las herramientas necesarias.
Lo que no podés concluir sin experimento propio
Seré directo sobre los límites de este análisis:
- Latencia real por patrón: no tengo números públicos propios de producción para comparar los 4 patrones en escenarios reales. Si querés medirlo, instrumentá con
console.timeen middleware local y compará con Edge Functions Logs en Vercel. - Comportamiento en Cloudflare Workers: Next.js 16 deployado en Workers puede tener diferencias de edge runtime respecto a Vercel Edge. La documentación oficial cubre el subset garantizado; el resto depende del provider.
- Race condition de cookies en todos los browsers: la race condition de sesión nueva + redirect inmediato es reproducible en condiciones específicas. No es universal — depende del timing del cliente y del provider de hosting.
Lo que sí está respaldado por documentación oficial: las limitaciones del edge runtime, los módulos no disponibles y el comportamiento del matcher son descritos en Next.js Docs — Middleware y Next.js Docs — Edge Runtime.
FAQ — Preguntas frecuentes sobre Next.js 16 Middleware y autorización
¿Puedo usar jsonwebtoken en el middleware de Next.js 16?
No de manera confiable. jsonwebtoken depende del módulo crypto de Node.js, que no está disponible en edge runtime. La alternativa recomendada es jose, que usa Web Crypto API y funciona en edge. Verificá siempre la compatibilidad de dependencias con el listado oficial de Edge Runtime APIs.
¿El middleware de Next.js 16 reemplaza la validación en los route handlers? No, y es un error pensarlo así. El middleware protege el perímetro externo de la app. Los route handlers pueden ser invocados internamente (Server Actions, fetch server-side) sin pasar por middleware. Si solo protegés en middleware, tenés un bypass silencioso en el surface interno.
¿Cuándo tiene sentido hacer verificación criptográfica completa en middleware?
Cuando el token no tiene revocación activa y la librería es compatible con edge runtime (jose). Si necesitás consultar base de datos para verificar si el token fue revocado, ese costo en cada request escala mal. En ese caso, verificá la forma en middleware y hacé la consulta real downstream.
¿Por qué pueden aparecer loops de redirect en App Router con middleware?
El App Router tiene su propio sistema de navegación con prefetching. Un NextResponse.redirect en middleware puede interferir con requests prefetcheados, generando ciclos si la condición de redirect se evalúa también en el destino. La regla práctica: usá redirect solo para "no hay sesión", y rewrite o headers para comunicar estado al resto del sistema.
¿El matcher afecta la performance aunque el middleware no haga nada?
Sí. Cada request que matchea ejecuta el middleware, aunque sea para hacer NextResponse.next() inmediatamente. Un matcher demasiado amplio que incluye assets estáticos suma overhead innecesario. El patrón de exclusión con regex negativo ((?!_next/static|...)) es la forma correcta de limitar el scope.
¿Tiene sentido componer middlewares en Next.js 16 si no hay soporte nativo? Tiene sentido si el proyecto crece en complejidad de auth (múltiples roles, múltiples paths protegidos). El costo es parsear el JWT en cada función del pipeline. La optimización es parsear una sola vez al inicio y pasar el resultado como header interno. Si el proyecto es simple, un middleware monolítico bien comentado es más mantenible que una cadena de funciones.
Conclusión: el middleware no es tu capa de autorización principal
Mi postura es incómoda para quienes aprendieron Next.js con tutoriales de "protegé tu app en 10 minutos": el middleware es excelente para hacer el check más barato de todos — ¿hay algo que parece un token? — y redirigir rápido cuando no hay nada. Es un guardia de presencia, no un auditor.
La autorización real — permisos, roles, revocación, acceso a recursos específicos — pertenece a capas que tienen acceso completo a las herramientas que necesitás: Server Components, Route Handlers, Server Actions. Esas capas corren en Node.js completo, tienen acceso a base de datos y pueden usar cualquier librería.
Lo incómodo es que este split requiere que escribas validación en dos lugares. Pero la alternativa — poner toda la lógica en middleware y confiar en que el edge runtime tiene todo lo que necesitás — es la receta para los problemas que describí arriba.
Si trabajás con TypeScript strict en el mismo proyecto, el post sobre las opciones de tsconfig que más impactan en producción tiene contexto complementario. Y si estás pensando en caching de App Router junto con auth, el post de Next.js App Router caching tiene las interacciones que hay que entender antes de mezclar las dos cosas.
El próximo paso concreto: abrí el middleware.ts propio, fijate qué está haciendo realmente y preguntate si cada operación pertenece a edge o a Node.js. La respuesta a esa pregunta define qué tan bien escala el sistema cuando el tráfico crece.
Fuentes:
Artículos Relacionados
Prisma 5 → Prisma 6: los breaking changes que encontré en mi schema real y cómo los resolví sin romper producción
Prisma 6 mejora ergonomía y performance, pero hay tres cambios de comportamiento que no gritan en el compilador y sí aparecen en runtime si no revisás tus queries relacionales. Guía práctica con checklist.
tsgo: qué cambia en el compilador TypeScript reescrito en Go y qué significa para proyectos reales
tsgo es real y el salto de performance es verificable, pero la beta tiene límites documentados que la mayoría ignora. Acá está el criterio concreto para saber si vale explorarlo hoy o si conviene esperar la stable.
React 19 use() hook y Suspense: cuándo reemplaza useEffect y cuándo te mete en un loop peor
El hook use() de React 19 promete reemplazar useEffect para data fetching. La promesa es parcialmente cierta. Hay dos patrones con Suspense y error boundaries donde el comportamiento no es el que esperás y el ciclo se complica más. Te explico exactamente cuándo migrar y cuándo no.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.