¿Alguna vez te has preguntado cómo implementa Next.js la protección contra CSRF si no usa los tokens tradicionales? Recientemente, tuve la oportunidad de analizar cómo Next.js gestiona esta importante medida de seguridad.
Next.js no protege contra CSRF usando tokens. Protege validando el origen real de la solicitud y apoyándose en el comportamiento moderno de los navegadores.
En este artículo explico cómo funciona realmente esa protección, por qué es suficiente en Server Actions y en qué casos no lo es.
¿Qué es un ataque CSRF?
Antes de entrar en el tema principal, repasemos qué es CSRF.
El Cross-Site Request Forgery (CSRF) es un tipo de ataque en el que un atacante obliga a un usuario a enviar una solicitud no intencionada. Para entenderlo mejor, puedes consultar mi guía completa sobre qué es un ataque CSRF.
A continuación, se visualiza el flujo de un ataque de este tipo.
El formulario de envío automático que devuelve la página maliciosa evil.example tendría un aspecto similar al siguiente. El atacante establece el valor del campo to a su propia cuenta y el amount a una cantidad arbitraria (y grande). Si el ataque tiene éxito, la cantidad especificada se transferirá a la cuenta del atacante.
<body onload="document.forms[0].submit()">
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="100000">
</form>
</body>Este ataque es posible debido a una especificación del navegador. En la documentación de MDN sobre cookies HTTP se indica lo siguiente:
When a new request is made, the browser usually sends previously stored cookies for the current domain back to the server within a Cookie HTTP header
Como especificación básica, el navegador adjunta automáticamente las cookies en las solicitudes al mismo servidor. Por eso, incluso cuando se envía el formulario desde evil.example, si el usuario ha iniciado sesión en bank.example, la solicitud se ejecuta utilizando la cookie de sesión.
Medidas comunes contra CSRF
Una de las contramedidas más utilizadas contra CSRF ha sido el uso de “Tokens CSRF”. Este método es recomendado en la CSRF Cheat Sheet de OWASP.
Este método verifica si una solicitud proviene de una página legítima de la siguiente manera:
- El servidor genera previamente un token aleatorio difícil de predecir. Este token se guarda asociado al ID de sesión.
- El token se incluye en la respuesta. Un método común es incluirlo en un campo oculto (
hidden field). - El cliente envía la solicitud al servidor incluyendo este token.
- El servidor compara el token recibido con el que está guardado junto al ID de sesión para confirmar que coinciden.
En esencia, se establece una “contraseña” temporal y se utiliza para validar las solicitudes. Solo el cliente que recibió la respuesta directamente del servidor conoce esta contraseña.
Esto se debe a la Política Same-origin, que impide que un atacante pueda obtener el token de otro usuario, lo que significa que no hay forma de incrustar el token en el formulario del atacante.
Ejemplo de protección CSRF en Laravel
Veamos un ejemplo concreto con el framework de PHP, Laravel. Laravel utiliza el método de tokens para su protección CSRF. He elegido Laravel porque implementa esta técnica y porque ya he escrito un artículo sobre la protección CSRF en este framework, lo que facilita la explicación.
Aunque los detalles se encuentran en mi artículo anterior, lo explicaré aquí de forma concisa.
En Laravel, simplemente escribiendo @csrf en una plantilla Blade, se genera automáticamente un campo input oculto con el token.
<form method="POST" action="/transfer">
@csrf
<!-- <input type="hidden" name="_token" value="Token(cadena aleatoria)"> -->
</form>El token se genera como una cadena aleatoria al inicializar la sesión y se almacena en el archivo de sesión del servidor. Con la configuración por defecto, el token se guarda utilizando el ID de sesión como nombre de archivo, de la siguiente manera:
a:4:{s:6:"_token";s:40:"G5FzKXaCYA4w8kdWbftEZMYoglQgD9yPIG9r2zzx";s:9:"_previous";a:1:{s:3:"url";s:29:"http://127.0.0.1:8085/profile";}s:6:"_flash";a:2:{s:3:"new";a:0:{}}s:50:"login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d";i:1;}Cuando se envía el formulario, el middleware VerifyCsrfToken inspecciona la solicitud y la rechaza si el token no coincide. Las solicitudes con los métodos GET, HEAD y OPTIONS están exentas de esta verificación. Como dato adicional, Laravel utiliza hash_equals() para ofrecer resistencia contra ataques de temporización.
/**
* Determina si los tokens CSRF de la sesión y de la entrada coinciden.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function tokensMatch($request)
{
$token = $this->getTokenFromRequest($request);
return is_string($request->session()->token()) &&
is_string($token) &&
hash_equals($request->session()->token(), $token);
}
/**
* Determina si la solicitud HTTP utiliza un verbo de 'lectura'.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function isReading($request)
{
return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
}¿Cuál es el problema fundamental de CSRF?
Hasta ahora he analizado la protección CSRF mediante tokens. Pero si lo pienso bien, ¿qué es lo que realmente se está intentando comprobar con este método? La coincidencia del token no es el aspecto fundamental.
El problema real de CSRF es que el servidor no puede determinar desde dónde se originó una solicitud autenticada. Mientras no sea posible distinguir si una petición fue generada por el propio sitio o por un sitio externo, existe una superficie de ataque.
Cuando escribí el artículo sobre la protección CSRF en Laravel, mi análisis se detuvo en el método de tokens. Más adelante entendí que los tokens son solo un medio para mitigar ese problema de fondo, no el problema en sí.
Aquí está la clave: el problema fundamental de CSRF es la incapacidad de determinar el origen de una solicitud. El método de tokens CSRF es solo un medio para resolver este problema.
Otro problema es el envío de cookies en solicitudes cross-site. Si las cookies no se enviaran en estas solicitudes, la autenticación fallaría desde el principio.
Protección CSRF Moderna: Más Allá de los Tokens
Conocer el origen de la solicitud sin usar tokens
Es posible conocer el origen de una solicitud examinando el valor de la cabecera Origin.
El navegador adjunta automáticamente la cabecera Origin a las solicitudes cross-origin y a las solicitudes POST same-origin. El valor es el origen de la página que generó la solicitud (esquema + host + puerto), sin incluir la ruta, tal como lo detalla la documentación de MDN sobre Origin.
Resumiendo el contenido de MDN, la cabecera Origin se adjunta en los siguientes casos:
| Condición | Origin adjuntado | Notas |
|---|---|---|
| Cross-origin × GET/HEAD | ✅ | Con excepciones que se mencionan más adelante |
| Cross-origin × POST/PUT/PATCH/DELETE/OPTIONS | ✅ | |
| Same-origin × POST/PUT/PATCH/DELETE/OPTIONS | ✅ | Métodos diferentes a GET/HEAD |
| Same-origin × GET/HEAD | ❌ | No se adjunta |
Basado en el ejemplo de ataque CSRF inicial:
- Solicitud legítima
Origin: https://bank.example - Solicitud de ataque
Origin: https://evil.example
De esta manera, es posible determinar el origen de la solicitud.
Evitar el envío de cookies en solicitudes cross-site
Según la guía de MDN sobre SameSite cookies, el atributo SameSite controla el envío de cookies durante solicitudes cross-site. Al establecerlo en Lax o Strict, las cookies no se adjuntan en las solicitudes POST cross-site. Cabe destacar que las versiones recientes de Chrome aplican Lax por defecto.
| Tipo de solicitud | Strict | Lax | None |
|---|---|---|---|
| Solicitud desde el mismo sitio | Se envía | Se envía | Se envía |
| Clic en un enlace desde otro sitio (navegación de nivel superior) | No se envía | Se envía | Se envía |
| Formulario POST desde otro sitio | No se envía | No se envía | Se envía |
| fetch/XHR desde otro sitio | No se envía | No se envía | Se envía |
| Carga dentro de un iframe desde otro sitio | No se envía | No se envía | Se envía |
Por lo tanto, incluso si se realiza un POST desde el sitio del atacante, la cookie no llegará al servidor y la autenticación fallará en navegadores modernos y con SameSite correctamente configurado.
Prioridades modernas para mitigar CSRF
En la práctica moderna de mitigación CSRF, el orden de prioridad suele ser el siguiente:
- Uso del método POST
- Verificación de la cabecera
Origin - Configuración explícita de
SameSite Cookie - Verificación de Fetch Metadata (
Sec-Fetch-Site)
- Uso del método POST
Para operaciones que conllevan efectos secundarios (modificación o eliminación de datos), se debe usar siempre POST. Como muestra la tabla anterior, a las solicitudes GET cross-site no se les adjunta la cabecera Origin. Por lo tanto, se asume que son operaciones de solo lectura sin efectos secundarios.
- Verificación de la cabecera
Origin
Verificar este valor en el servidor permite validar directamente el origen. - SameSite Cookie
Establecer explícitamenteSameSite=LaxoStrictevita que las cookies se envíen a orígenes cruzados. - Fetch Metadata (
Sec-Fetch-Site)
La cabeceraSec-Fetch-Sitepermite conocer con más detalle de dónde proviene una solicitud.Sec-Fetch-Site: same-origin# Desde el mismo origenSec-Fetch-Site: cross-site# Desde un dominio diferenteSec-Fetch-Site: none# Acceso directo (barra de URL, etc.)
El Enfoque de Next.js: Protección sin Tokens
Después de esta necesaria introducción, llegamos al tema principal. El blog oficial de Next.js explica que el framework implementa una protección CSRF muy similar al enfoque moderno descrito anteriormente.
El blog lo indica claramente:
Behind the scenes, Server Actions are always implemented using POST and only this HTTP method is allowed to invoke them. This alone prevents most CSRF vulnerabilities in modern browsers, particularly due to Same-Site cookies being the default.
As an additional protection Server Actions in Next.js 14 also compares the Origin header to the Host header (or X-Forwarded-Host). If they don’t match, the Action will be rejected.
Server Actions doesn’t use CSRF tokens, therefore HTML sanitization is crucial.
Blog de Next.js
En resumen, las Server Actions de Next.js emplean una triple capa de protección contra CSRF:
| Protección | Mecanismo |
|---|---|
| SameSite Cookie | Las cookies no se adjuntan en solicitudes POST cross-site |
| Solo permitir POST | Las Server Actions no se activan con métodos como GET |
| Comparación Origin / Host | Si Origin y Host no coinciden, la solicitud se rechaza |
Esto significa que la protección CSRF en Next.js no se basa en el método tradicional de tokens, sino que sigue las prácticas modernas.
Análisis del código fuente de Next.js
Aunque el blog oficial afirma que estas medidas están implementadas, persiste una cierta duda: “¿es realmente así?”.
Pero, ¿podemos fiarnos de la documentación? Aquí es donde la cosa se pone interesante. Verificaremos el código fuente para confirmar cómo se implementa la restricción a POST y la comparación entre Origin y Host.
Diagrama de flujo general
El flujo del proceso es complejo, así que primero presento una vista general del recorrido hasta la validación CSRF.
Sobre las Server Actions
Como complemento, describiré brevemente qué son las Server Actions. Son funciones del lado del servidor que se pueden invocar directamente desde el cliente.
// app/actions.ts
'use server' // ← Esta declaración la convierte en una Server Action
export async function createUser(formData: FormData) {
const name = formData.get('name')
await db.insert({ name }) // Puede acceder directamente a la BD en el servidor
}
// app/page.tsx
import { createUser } from './actions'
export default function Page() {
return (
<form action={createUser}> {/* ← Pasar la Server Action al formulario */}
<input name="name" />
<button type="submit">Enviar</button>
</form>
)
}A las funciones declaradas con 'use server', se les asigna un actionId único durante el proceso de compilación. En el lado del cliente, la llamada a esa función se convierte automáticamente en una solicitud POST. Por esta razón, las Server Actions están diseñadas para activarse únicamente mediante solicitudes POST.
Para entender lo que sigue, basta con saber “qué es una Server Action” y que “se activan solo con solicitudes POST”.
Proceso para permitir solo POST
Como se mencionó en la sección anterior, las Server Actions están diseñadas para activarse únicamente mediante solicitudes POST.
Cuando el servidor recibe una solicitud, se invoca handleAction() en app-render.tsx, que a su vez llama a getServerActionRequestMetadata(req) en action-handler.ts.
Aquí se verifica si la solicitud puede procesarse como una Server Action, incluyendo la comprobación de que el método sea POST.
// No se da soporte a las "actions" codificadas como URL, y el manejador de acciones
// se detendrá si encuentra una. Sin embargo, se permite que la solicitud continúe
// hacia el manejador para evitar cambios de comportamiento cuando un componente de página
// regular intenta procesar un POST.
const isURLEncodedAction = Boolean(
req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
)
const isMultipartAction = Boolean(
req.method === 'POST' && contentType?.startsWith('multipart/form-data')
)
const isFetchAction = Boolean(
actionId !== undefined &&
typeof actionId === 'string' &&
req.method === 'POST'
)
const isPossibleServerAction = Boolean(
isFetchAction || isURLEncodedAction || isMultipartAction
)De este modo, las Server Actions emplean varias capas de defensa para asegurar que solo las solicitudes POST sean procesadas.
Proceso para comparar Origin y Host
Si isPossibleServerAction es true, se procede a la comparación de Origin y Host dentro de handleAction() en action-handler.ts.
El proceso consiste en obtener primero el valor de la cabecera Origin. Si es Origin: null, se trata como undefined. El valor de la cabecera Host se obtiene mediante parseHostHeader(), que prioriza la cabecera x-forwarded-host sobre host.
Luego, se comparan Origin y Host. El resultado es el siguiente:
- Si no se obtiene el valor de la cabecera
Origin, se emite una advertencia y el proceso continúa. Esta especificación, como se indica en un comentario en el código fuente, es para dar soporte a navegadores antiguos que no envían esta cabecera. - Si coinciden, el proceso continúa.
- Si no coinciden, pero el origen está incluido en
isCsrfOriginAllowed, se permite. - Si no coinciden y no está en
isCsrfOriginAllowed, se considera un ataque y el proceso se detiene.
De esta forma se previene un ataque CSRF.
const originHeader = req.headers['origin']
const originDomain =
typeof originHeader === 'string' && originHeader !== 'null'
? new URL(originHeader).host
: undefined
const host = parseHostHeader(req.headers)
let warning: string | undefined = undefined
function warnBadServerActionRequest() {
if (warning) {
warn(warning)
}
}
// Esto es para prevenir ataques CSRF. Si `x-forwarded-host` está establecido,
// debemos asegurar que la solicitud proviene del mismo host.
if (!originDomain) {
// Este podría ser un navegador antiguo que no envía la cabecera `host`.
// Ignoramos este caso.
warning = 'Missing `origin` header from a forwarded Server Actions request.'
} else if (!host || originDomain !== host.value) {
// Si el cliente establece una lista de orígenes permitidos, permitiremos la solicitud.
// Estos se consideran seguros pero podrían ser diferentes del host reenviado
// establecido por la infraestructura (p. ej., proxies inversos).
if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
// Ignorarlo
} else {
if (host) {
// Esto parece ser un ataque CSRF. No debemos proceder con la acción.
console.error(
`\`${
host.type
}\` header with value \`${limitUntrustedHeaderValueForLogs(
host.value
)}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs(
originDomain
)}\` from a forwarded Server Actions request. Aborting the action.`
)
} else {
// Esto es un ataque. No debemos proceder con la acción.
console.error(
`\`x-forwarded-host\` or \`host\` headers are not provided. One of these is needed to compare the \`origin\` header from a forwarded Server Actions request. Aborting the action.`
)
}
}
}export function parseHostHeader(
headers: IncomingHttpHeaders,
originDomain?: string
) {
const forwardedHostHeader = headers['x-forwarded-host']
const forwardedHostHeaderValue =
forwardedHostHeader && Array.isArray(forwardedHostHeader)
? forwardedHostHeader[0]
: forwardedHostHeader?.split(',')?.[0]?.trim()
const hostHeader = headers['host']
if (originDomain) {
return forwardedHostHeaderValue === originDomain
? {
type: HostType.XForwardedHost,
value: forwardedHostHeaderValue,
}
: hostHeader === originDomain
? {
type: HostType.Host,
value: hostHeader,
}
: undefined
}
return forwardedHostHeaderValue
? {
type: HostType.XForwardedHost,
value: forwardedHostHeaderValue,
}
: hostHeader
? {
type: HostType.Host,
value: hostHeader,
}
: undefined
}isCsrfOriginAllowed
Analicemos también isCsrfOriginAllowed. La implementación se encuentra en packages/next/src/server/app-render/csrf-protection.ts.
export const isCsrfOriginAllowed = (
originDomain: string,
allowedOrigins: string[] = []
): boolean => {
// Los nombres DNS no distinguen mayúsculas de minúsculas según el RFC 1035
// Usar toLowerCase solo para ASCII para evitar problemas con unicode
const normalizedOrigin = originDomain.replace(/[A-Z]/g, (c) =>
c.toLowerCase()
)
return allowedOrigins.some((allowedOrigin) => {
if (!allowedOrigin) return false
const normalizedAllowed = allowedOrigin.replace(/[A-Z]/g, (c) =>
c.toLowerCase()
)
return (
normalizedAllowed === normalizedOrigin ||
matchWildcardDomain(originDomain, allowedOrigin)
)
})
}Esta función verifica si el dominio de la cabecera Origin de la solicitud está incluido en la lista de dominios permitidos del servidor, allowedOrigins. Esto está documentado en la web oficial de Next.js.
A list of extra safe origin domains from which Server Actions can be invoked. Next.js compares the origin of a Server Action request with the host domain, ensuring they match to prevent CSRF attacks. If not provided, only the same origin is allowed.
Blog Next.js
Más Allá de Server Actions: ¿Qué Pasa con los Route Handlers?
Se ha confirmado que tanto el uso exclusivo de POST como la comparación entre Origin y Host están correctamente implementados en las Server Actions. En ese contexto, la protección funciona como se describe.
Pero esto no cubre todos los puntos de entrada de una aplicación. Es necesario considerar los Custom Route Handlers (route.ts). La protección CSRF descrita hasta ahora funciona exclusivamente para las Server Actions, ya que los Custom Route Handlers (o la seguridad en API Routes de Next.js, como se conocían antes) siguen una ruta de procesamiento diferente y no pasan por estas validaciones.
El propio blog oficial de Next.js advierte sobre este punto:
When Custom Route Handlers (
route.ts) are used instead, extra auditing can be necessary since CSRF protection has to be done manually there.
Por esta razón, la protección de Route Handlers en Next.js requiere que implementemos por separado las medidas de protección CSRF modernas o el método tradicional de tokens. Para estos casos, una [checklist de seguridad para el backend puede ser de gran ayuda.
Todo comenzó con una publicación casual en X que tuvo bastante repercusión.
Gracias a ello, tuve la oportunidad de profundizar en el tema y, de hecho, fue una excelente ocasión para aprender muchas cosas.
Preguntas Frecuentes sobre la Seguridad CSRF en Next.js
¿Necesito tokens CSRF en Next.js con Server Actions?
No por defecto. Las Server Actions utilizan una combinación de SameSite Cookies, restricción al método POST y la verificación del header Origin para una protección robusta.
¿La protección CSRF de Next.js funciona en todos los navegadores?
Depende de navegadores modernos que envíen la cabecera Origin y respeten el atributo SameSite de las cookies. Next.js tiene un fallback para navegadores muy antiguos (no bloquea la petición si falta el Origin), pero la protección es más débil.
¿Qué es más seguro, tokens CSRF o la verificación de Origin?
No es una cuestión de cuál es “más seguro”, sino de contexto. En navegadores modernos, saber cómo evitar CSRF en Next.js pasa por validar Origin y configurar correctamente SameSite, lo que cubre la mayoría de los ataques CSRF reales. Los tokens siguen siendo necesarios cuando ese contexto no está garantizado o cuando se trabaja fuera del modelo de Server Actions.