Checklist de seguridad para el App Router de Next.js
Checklist práctica para apps con App Router de Next.js: server actions, route handlers, middleware, variables de entorno, CSRF, cabeceras y caché.
· 8 min de lectura · Lina Source LLC
El App Router devolvió mucho código del navegador al servidor. Eso es sobre todo una victoria para la seguridad: las consultas, los secretos y la lógica de negocio se ejecutan ahora en un sitio que los usuarios no pueden leer. También difuminó la línea entre lo que es un endpoint público y lo que solo parece una llamada a una función. La mayoría de los bugs serios en aplicaciones Next.js vienen de esa confusión.
Esta checklist cubre los problemas que vemos con más frecuencia en bases de código con App Router, en el orden en el que merece la pena comprobarlos. Nada de esto es exótico; cada punto es un sitio donde la comodidad del framework esconde una frontera de confianza.
1. Las server actions son endpoints públicos
Una función marcada con 'use server' se compila a un endpoint HTTP. Cualquiera que pueda cargar tu sitio puede encontrar el ID de la acción y llamarla con argumentos arbitrarios, tanto si tu interfaz renderiza el botón que la usa como si no. Ocultar un formulario a quien no es administrador no protege la acción que hay detrás.
Cada acción necesita los mismos tres pasos que cualquier handler de API: autenticar a quien llama, validar la entrada y autorizar el registro concreto que se está tocando. Pon estas comprobaciones dentro del cuerpo de la propia acción. Una comprobación en el componente de página que renderiza el formulario se ejecuta cuando carga la página, no cuando se llama a la acción, así que no protege nada.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const Input = z.object({
projectId: z.string().uuid(),
name: z.string().trim().min(1).max(100),
});
export async function renameProject(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const { projectId, name } = Input.parse({
projectId: formData.get('projectId'),
name: formData.get('name'),
});
// La comprobación de propiedad forma parte de la escritura, no es una consulta aparte
const result = await db.project.updateMany({
where: { id: projectId, ownerId: session.user.id },
data: { name },
});
if (result.count === 0) throw new Error('Not found');
revalidatePath('/projects');
}Vigila los archivos de helpers que exportan muchas acciones. Toda función exportada en un módulo 'use server' es invocable, incluida la que alguien añadió para un script interno y luego olvidó.
2. Los route handlers necesitan las mismas comprobaciones
Los route handlers en app/api son endpoints de forma más evidente, pero comparten el mismo modo de fallo: un segmento dinámico se usa para cargar un registro sin comprobar de quién es. En las versiones recientes de Next.js, params es una Promise; haz await, valídalo y acota la consulta a quien llama.
// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
const { id } = await params;
const doc = await db.document.findFirst({
where: { id, ownerId: session.user.id },
});
if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });
return Response.json(doc, {
headers: { 'Cache-Control': 'private, no-store' },
});
}Devuelve 404 en lugar de 403 para los registros que no pertenecen a quien llama, para que el endpoint no confirme qué ID existen. Nunca confíes en params, searchParams, cabeceras ni cookies para tomar decisiones de autorización; todos son entrada controlada por el atacante.
3. El middleware por sí solo no es una frontera de autorización
El middleware (renombrado proxy en Next.js 16) es útil para redirigir a los usuarios sin sesión y para poner cabeceras. No debería ser el único sitio donde ocurre la autorización. Los matchers son fáciles de equivocar, las rutas nuevas se añaden fuera de ellos y las server actions hacen POST a la ruta de la página, que puede no coincidir con el patrón que tenías en mente. En 2025, CVE-2025-29927 mostró que una cabecera interna manipulada podía hacer que algunas versiones de Next.js se saltaran el middleware por completo.
Trata el middleware como una capa de conveniencia. La comprobación de verdad va junto a los datos: en cada acción y handler o, mejor, en una capa de acceso a datos por la que pase toda lectura del lado del servidor. Una capa de acceso a datos es un único módulo server-only que expone funciones como getProjectForUser(projectId) y realiza dentro la comprobación de sesión y el filtro de propiedad. Las páginas, las acciones y los route handlers la llaman en lugar de llamar directamente al cliente de la base de datos, así que una ruta nueva no puede olvidarse de la comprobación: no hay ninguna vía sin comprobar que olvidar.
4. Mantén el código de servidor en el servidor
Cualquier variable de entorno con el prefijo NEXT_PUBLIC_ se incrusta en el bundle de JavaScript en tiempo de build y es visible para cualquier visitante. Eso es correcto para una clave publicable de Stripe o un ID de analítica, y es una fuga para cualquier otra cosa. Busca NEXT_PUBLIC_ en la base de código y comprueba que cada una sería segura en una valla publicitaria. El error inverso también pasa: una variable sin el prefijo se lee en un client component, llega como undefined en el navegador y alguien la renombra a NEXT_PUBLIC_ para que el error desaparezca. Si un valor hace falta en el navegador, pregúntate primero si el navegador debería tenerlo siquiera.
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export const getCurrentUser = cache(async () => {
const session = await auth();
if (!session?.user) return null;
// Devuelve solo los campos que necesita la UI, nunca la fila entera
return db.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, plan: true },
});
});El paquete server-only hace que el build falle si un client component importa el módulo, lo que protege a los clientes de base de datos y a los helpers que leen secretos de acabar en el navegador. Vigila también lo que los server components pasan como props a los client components: todo lo que cruza esa frontera se serializa dentro de la página, así que pasar una fila de usuario completa envía al navegador su hash de contraseña y sus flags internos.
5. CSRF: entiende qué cubre el framework
Las server actions solo aceptan POST, y Next.js compara la cabecera Origin con el host antes de ejecutarlas. Si despliegas detrás de un proxy o en varios dominios, configura serverActions.allowedOrigins de forma deliberada en lugar de ampliarlo hasta que dejen de salir errores.
Los route handlers no reciben esa protección. Si un handler POST, PUT o DELETE se autentica con cookies, un formulario en otro sitio todavía puede enviarle datos. Comprobar el content type no basta por sí solo: un formulario HTML puede enviar cuerpos urlencoded, multipart y text/plain sin preflight, y un handler que parsea el cuerpo con laxitud los aceptará. Pon las cookies de sesión con SameSite=Lax o Strict, nunca hagas cambios de estado en GET y comprueba la cabecera Origin en las mutaciones autenticadas con cookies. Los handlers que solo aceptan un bearer token en la cabecera Authorization no están expuestos al CSRF clásico.
6. Cabeceras de seguridad y CSP
Next.js envía muy pocas cabeceras de seguridad por defecto. Añádelas en next.config mediante la función headers(), o en el middleware cuando necesites un nonce por petición para una Content-Security-Policy estricta.
- Content-Security-Policy, idealmente basada en nonces, con object-src 'none' y base-uri 'self'.
- Strict-Transport-Security con un max-age largo cuando HTTPS esté sólido en todas partes.
- X-Content-Type-Options: nosniff.
- Referrer-Policy: strict-origin-when-cross-origin.
- frame-ancestors en la CSP, o X-Frame-Options, para prevenir el clickjacking.
- poweredByHeader: false en next.config, para eliminar la cabecera X-Powered-By.
7. Rate limiting
No hay un rate limiter integrado. El inicio de sesión, el registro, el restablecimiento de contraseña, la verificación de OTP y todo lo que cuesta dinero por llamada (correo, SMS, peticiones de IA) necesitan límites por usuario y por IP. Los contadores en memoria no funcionan en despliegues serverless o con varias instancias; usa un almacén compartido como Redis o tu base de datos. Aplica los límites dentro de la acción o del handler, donde se conoce al usuario autenticado, y no solo en el middleware. Basa los límites en algo que un atacante no pueda rotar barato: la cuenta para las rutas autenticadas, y el correo o el teléfono de destino en los flujos de restablecimiento y OTP, además de la IP. Devuelve 429 con una cabecera Retry-After para que los clientes legítimos se retiren correctamente.
8. Verifica los webhooks contra el cuerpo crudo
Las firmas de los webhooks se calculan sobre los bytes exactos que envió el proveedor. Parsear el cuerpo como JSON y volver a serializarlo cambia esos bytes y rompe la verificación, lo que tienta a la gente a saltársela. En un route handler, lee el cuerpo con req.text() y verifica antes de hacer ninguna otra cosa.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Los handlers deben ser idempotentes: los proveedores reintentan y pueden entregar dos veces
await handleStripeEvent(event);
return new Response('ok');
}Guarda los ID de evento procesados con una restricción de unicidad para que una entrega reintentada o reproducida no conceda una suscripción dos veces, y gestiona los eventos que llegan desordenados, ya que los proveedores no garantizan el orden de entrega.
9. No cachees datos por usuario de forma global
El App Router cachea de forma agresiva, y los valores por defecto han cambiado entre versiones. Una función envuelta en unstable_cache o en 'use cache' que devuelve datos para «el usuario actual» pero no incluye el ID de usuario en su clave de caché servirá los datos de un usuario al siguiente. Lo mismo vale para las páginas renderizadas de forma estática que deberían ser dinámicas, y para el cacheo de respuestas de API en la CDN. Pruébalo directamente: inicia sesión con dos usuarios distintos en dos navegadores y carga las mismas páginas. Cualquier cosa que muestre los datos del primer usuario al segundo es un bug de caché, y normalmente uno grave.
- Pasa el ID de usuario o de tenant de forma explícita a las funciones cacheadas para que forme parte de la clave.
- No leas cookies ni cabeceras dentro de funciones cacheadas; léelas fuera y pasa los valores.
- Envía Cache-Control: private, no-store en las respuestas que contienen datos por usuario.
- Revisa la salida del build: las rutas que esperas que sean dinámicas no deberían aparecer como estáticas.
Cómo aplicar la checklist
Recorre la lista por ruta, no por archivo: para cada acción y handler, quién puede llamarla, qué entrada se cree, qué cachea y qué devuelve. CodeAuditAgent puede dar una primera pasada sobre un repositorio público de GitHub o un fragmento pegado, reportando cada hallazgo con su severidad, el CWE, la línea citada y un parche propuesto; las preguntas de diseño, como qué rutas deberían ser públicas siquiera, siguen necesitando el criterio de tu equipo.