Cross-Site Request Forgery (CSRF)

Cómo detectar y prevenir vulnerabilidades CSRF, tanto en aplicaciones tradicionales como en arquitecturas SPA con APIs REST.

1. El problema

1.1. ¿Qué es Cross-Site Request Forgery?

Cross-Site Request Forgery (CSRF) es una vulnerabilidad que permite a un atacante provocar operaciones no deseadas en una aplicación web aprovechando la autenticación de otro usuario.

Para entender cómo actúa, antes hay que ver cómo funcionan las cookies de sesión.

HTTP, el protocolo en el que se basa internet, es sin estado: cada vez que haces clic en un enlace o recargas una página, el servidor te olvida por completo y piensa que es tu primera vez. Para darle memoria, el servidor puede pedirle al navegador que guarde cookies, pequeños fragmentos de texto que el navegador conserva y devuelve después.

Para mantener una sesión, la aplicación guarda sus datos en el servidor y envía al navegador una cookie con un identificador. El navegador la conserva y la adjunta automáticamente a las peticiones que cumplen sus reglas de envío. Así, tras iniciar sesión, el servidor reconoce al usuario por el identificador sin pedirle las credenciales en cada página ni en cada operación.

Simplificando: un usuario inicia sesión en https://bank.example.com y el servidor envía al navegador una cookie con un identificador de sesión, por ejemplo abc123 (en realidad es mucho más largo). En las siguientes peticiones, el navegador devuelve esa cookie de forma automática y el servidor usa el identificador para reconocer la sesión.

Ahora visitamos otra web, controlada por un atacante, que contiene un formulario oculto capaz de enviar por lo bajinis una petición a nuestro banco: cambiar el correo o la contraseña, o ordenar una transferencia con todos nuestros ahorros a la cuenta del atacante. Como esa petición a https://bank.example.com/transferencias-sin-proteger lleva adjunta la cookie de sesión, el banco se la come sin rechistar y nos quedamos sin ahorros.

1.2. En código

Cuando cambiamos el correo desde la web del banco, el navegador envía una petición como esta:

POST /api/profile/email
Host: bank.example.com
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded

email=new@example.com

Un atacante podría preparar una página con el siguiente formulario:

<form
  action="https://bank.example.com/api/profile/email"
  method="POST"
>
  <input
    type="hidden"
    name="email"
    value="attacker@example.net"
  >
</form>

<script>
  document.forms[0].submit();
</script>

Al visitar la página del atacante, el script envía el formulario al banco. Si el navegador adjunta la cookie de sesión y el banco no aplica ninguna protección adicional, aceptará el cambio de correo como si lo hubiéramos pedido nosotros. Y ahí se lía la cosa.

Veamos cómo sería el endpoint vulnerable con FastAPI:

from typing import Annotated

from fastapi import FastAPI, Form, Request

app = FastAPI()

@app.post("/api/profile/email")
def change_email(
    request: Request,
    email: Annotated[str, Form()],
):
    session = get_authenticated_session(request)

    update_user_email(
        user_id=session.user_id,
        email=email,
    )

    return {"ok": True}

Para simplificar, suponemos que get_authenticated_session() valida la cookie y recupera la sesión del usuario, y que update_user_email() guarda el nuevo correo.

El código comprueba quién es el usuario, pero no si fue él quien quiso hacer el cambio: no exige un token CSRF ni comprueba el origen de la petición. Si la cookie acompaña al formulario preparado por el atacante, el servidor ejecuta el cambio sin distinguirlo del formulario legítimo. Y aquí está la gracia del CSRF: el atacante no necesita leer la respuesta del servidor. Le basta con provocar la petición y conseguir que se ejecute la operación.

1.3. Consecuencias

Las consecuencias dependen de las operaciones disponibles en la aplicación y de los permisos de la víctima, pero pueden ser serias: cambiar su correo o su teléfono (y con ello, a menudo, hacerse con la cuenta), iniciar transferencias o modificar beneficiarios cuando esas funcionalidades carecen de protecciones adicionales, o cambiar permisos y configuraciones cuando la víctima tiene privilegios elevados.

1.4. ¿Y qué ocurre en una SPA con una API REST?

En una SPA hay que distinguir dos modelos de autenticación.

El primero es la autenticación mediante cookies:

fetch("/api/profile/email", {
  method: "POST",
  credentials: "same-origin",
  body: formData
});

El navegador incorpora automáticamente las cookies aplicables a la petición. Si un atacante consigue provocar una solicitud equivalente desde una página externa, el navegador adjuntará las cookies y puede haber CSRF.

El segundo, mucho más habitual hoy en día, es la autenticación mediante tokens Bearer:

fetch("/api/profile/email", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`
  },
  body: formData
});

Aquí es JavaScript quien añade explícitamente el token. Una página externa no puede conseguir que el navegador añada ese encabezado con el token de nuestra aplicación, así que una API que solo acepta tokens Bearer enviados explícitamente en Authorization no suele ser vulnerable al CSRF tradicional, que se basa en credenciales automáticas.

Pero hay una excepción frecuente: el endpoint de refresco. El access token Bearer caduca pronto, y es habitual que el endpoint que lo renueva (por ejemplo POST /auth/refresh) se autentique con una cookie que el navegador adjunta solo. Las rutas normales del API siguen recibiendo el Bearer de forma explícita, pero ese endpoint vuelve a ser una posible superficie de CSRF si la cookie viaja en una petición iniciada desde otro sitio.

Eso no significa que el atacante obtenga el nuevo access token. Si /auth/refresh lo devuelve en el cuerpo de la respuesta, la política de mismo origen normalmente impide que la página atacante lo lea, salvo que una configuración CORS insegura le dé acceso. Aun así, puede provocar la renovación y sus efectos secundarios (por ejemplo, una rotación de tokens que altere la sesión), según cómo esté implementado. Y si la respuesta establece credenciales en cookies, o el endpoint hace algo más que renovar, habría que evaluar también esos efectos.

El truquis, en cualquier caso, es mirar cada endpoint por separado. Si /auth/refresh depende de una cookie, protégelo frente a peticiones ajenas con las medidas CSRF adecuadas: una política SameSite apropiada y comprobaciones de origen o un token CSRF, según el flujo. Si el refresh token se envía solo de forma explícita desde JavaScript, no aparece ese mecanismo. La rotación del refresh token ayuda frente a su reutilización indebida, pero no sustituye a la protección CSRF del endpoint.

Y aun cuando las credenciales se envían de forma explícita, queda otra variante: el Client-Side CSRF.

1.5. Client-Side CSRF

En el CSRF tradicional, el atacante consigue que el navegador envíe una petición directamente a una aplicación vulnerable. En el Client-Side CSRF ocurre algo distinto: el atacante manipula información que el propio JavaScript legítimo de la aplicación utiliza para construir una petición HTTP.

En una SPA, parte de la lógica vive en el navegador. A veces ese código lee datos de la URL y los utiliza para llamar a la API. Eso está bien para abrir una pestaña o rellenar una búsqueda; la cosa cambia si esos datos desencadenan una operación sobre la cuenta sin que el usuario la confirme.

Imaginemos que la página de perfil contiene este código:

const params = new URLSearchParams(location.hash.slice(1));
const email = params.get("email");

if (email) {
  fetch("/api/profile/email", {
    method: "POST",
    credentials: "same-origin",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken
    },
    body: JSON.stringify({ email })
  });
}

Aquí csrfToken representa el token que la SPA ya obtuvo por su flujo legítimo. El código lee email del fragmento que aparece después de # y, si encuentra un valor, cambia el correo automáticamente. Ese es el fallo: la URL funciona como una orden para modificar la cuenta.

El atacante solo tiene que conseguir que la víctima abra este enlace:

https://app.example.com/profile#email=attacker@example.net

El fragmento #email=... no se envía al servidor al cargar la página. Lo lee el JavaScript de la propia SPA, que entonces hace esta petición:

POST /api/profile/email

Y aquí está la trampa: la petición sale del JavaScript legítimo de app.example.com. Ese código añade el token CSRF válido que ya tiene la aplicación; si usa Bearer, podría añadir también el encabezado Authorization. El navegador enviará las cookies que correspondan a una petición al mismo origen. El servidor ve una solicitud autenticada y bien formada, aunque el valor del correo viniera del enlace preparado por el atacante.

Por eso, en este ejemplo ni el token CSRF ni SameSite detectan la intención real del usuario. La defensa tiene que estar también en el frontend: no ejecutar cambios sensibles solo porque aparezcan datos en una URL. Y esto nos devuelve a dos conceptos que ya vimos en XSS: Sources y Sinks.

En el Client-Side CSRF, las Sources son los puntos por donde entran datos que un atacante puede controlar, y las Sinks son las operaciones que usan esos datos para construir peticiones HTTP:

SourcesSinks
location.searchfetch()
location.hashXMLHttpRequest
window.nameaxios.post()
document.referrernavigator.sendBeacon()
message.data de postMessageClientes HTTP personalizados
Funciones que construyen peticiones

Estas funciones no son peligrosas por sí mismas. El problema aparece cuando datos externos deciden cuándo se ejecutan y con qué valores.

1.6. Cómo detectarlo

Para detectar CSRF tradicional, empezamos identificando las operaciones que modifican el estado de la aplicación:

POST   /api/profile/email
POST   /api/orders
PUT    /api/users/123
PATCH  /api/settings
DELETE /api/documents/42

Después comprobamos cómo autentica cada una sus peticiones y qué protecciones adicionales aplica.

En un entorno de pruebas autorizado podemos reproducir una petición legítima, quitarle el token CSRF o los encabezados de protección y ver si el servidor la sigue aceptando. También conviene probar con el token de otra sesión: si lo acepta, el token no está vinculado a la sesión y no protege. Burp Suite Professional incluye un generador de pruebas de concepto de CSRF que ahorra escribir el formulario a mano. Y no olvides comprobar el comportamiento real desde otros orígenes, teniendo en cuenta las políticas SameSite y las restricciones del navegador.

Para el Client-Side CSRF, hay que revisar el código JavaScript y buscar flujos desde Sources manipulables hasta las funciones que realizan peticiones HTTP.

1.7. Errores frecuentes

El primero es pensar que usar POST evita CSRF: los formularios HTML pueden enviar peticiones POST entre orígenes. Su pariente es usar GET para modificar datos, porque algunas navegaciones entre sitios incluyen cookies con SameSite=Lax.

Luego están las confianzas mal puestas. En CORS: no impide que se envíen determinadas peticiones simples, aunque sí puede formar parte de una defensa basada en preflight. En HttpOnly: impide leer una cookie desde JavaScript, pero no evita que el navegador la envíe automáticamente. En SameSite: no es una protección absoluta y tiene limitaciones, sobre todo cuando intervienen subdominios del mismo sitio. Y en Bearer: no elimina cualquier forma de CSRF, porque el Client-Side CSRF aprovecha el JavaScript legítimo que incorpora esos tokens.

Y hay un fallo de implementación clásico: no vincular los tokens CSRF a la sesión. Un token que no se valida contra la sesión del usuario puede resultar inútil, porque el atacante usa el suyo propio.

2. La solución

La protección debe adaptarse al modelo de autenticación y a la arquitectura de la aplicación. Además, las defensas contra el CSRF tradicional no son las mismas que las necesarias contra el Client-Side CSRF, así que vamos por partes.

2.1. Proteger APIs REST mediante encabezados personalizados y CORS

Es una defensa sencilla de aplicar. Para APIs que reciben peticiones AJAX, podemos exigir que las operaciones sensibles incluyan un encabezado personalizado, por ejemplo:

X-Requested-By: my-application

Ojo a la jugada: un formulario HTML convencional no puede añadir ese encabezado.

Y para que se entienda por qué funciona, hay que saber qué es un preflight. Es una consulta previa que hace el navegador mediante OPTIONS antes de ciertas peticiones entre orígenes. Le pregunta al servidor si permite el origen de la página, el método HTTP y los encabezados que se quieren usar. Si la respuesta no los autoriza, el navegador no envía la petición real.

En nuestro caso, si una página ajena intenta hacer un fetch() con X-Requested-By, el navegador hará ese preflight antes del POST. Si la API no permite el origen atacante, el POST no saldrá. Y si el atacante recurre a un formulario HTML para evitar el preflight, el servidor rechazará la petición porque le faltará X-Requested-By.

Esta estrategia exige varias condiciones:

  • Rechazar las peticiones sensibles que no incluyan el encabezado exigido.
  • Configurar CORS con una lista cerrada de orígenes confiables.
  • No autorizar indiscriminadamente subdominios que puedan estar comprometidos.
  • No aceptar, mediante rutas alternativas, peticiones simples que eludan esa protección.

En las APIs JSON también podemos rechazar tipos de contenido innecesarios, como text/plain o application/x-www-form-urlencoded, y exigir application/json cuando corresponda.

Así, esta estrategia puede funcionar sin un token CSRF dedicado: el servidor exige el encabezado y solo autoriza mediante CORS los orígenes que deben poder enviarlo desde un navegador. Pero hay que diseñarla bien: configurar CORS sin exigir las características de la petición que activan esa protección no basta para impedir CSRF.

2.2. Utilizar SameSite y comprobar el origen

Otro buen remedio es configurar las cookies de sesión con una política SameSite adecuada. Este atributo le dice al navegador cuándo puede enviar la cookie en una petición que se inicia desde otro sitio. Por ejemplo:

Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

Con SameSite=Lax, la cookie no suele acompañar a un formulario POST enviado desde una web ajena, pero sí puede viajar cuando el usuario llega mediante un enlace normal (GET). SameSite=Strict también la retiene en esa navegación, lo que protege más, pero puede incordiar al usuario que entra desde un enlace externo.

Eso sí, hay que distinguir entre same-origin y same-site:

https://app.example.com
https://blog.example.com

Son orígenes diferentes porque cambia el subdominio, pero pertenecen al mismo sitio porque comparten esquema (https) y dominio registrable (example.com). Por eso, si blog.example.com no es confiable, no podemos dar por hecho que SameSite bloqueará una petición suya a app.example.com.

El servidor puede comprobar también de dónde viene la petición. Una solicitud iniciada desde ese blog podría incluir:

Origin: https://blog.example.com
Sec-Fetch-Site: same-site

Origin indica el origen que inició la petición. Sec-Fetch-Site es una cabecera de Fetch Metadata que indica la relación con el destino: same-origin, same-site o cross-site. Aquí dice same-site, aunque el origen no sea el de la aplicación. Si una operación solo debe iniciarse desde https://app.example.com, el backend puede rechazar la petición del blog.

Estas cabeceras no siempre estarán presentes. Si falta Origin, podemos comprobar Referer cuando exista. Si tampoco hay una señal fiable, el endpoint necesita otra protección, como un token CSRF, o debe rechazar la operación según la política de la aplicación.

Según la arquitectura, estas técnicas pueden ser la defensa principal o una capa adicional. No hace falta implantarlas todas a la vez, pero ninguna debería ir sola si la operación es sensible.

2.3. Prevenir Client-Side CSRF

En el Client-Side CSRF, el servidor recibe una petición válida porque la ha enviado el propio JavaScript de la aplicación. Por eso, la corrección está en decidir cuándo debe ejecutarse esa petición: un dato de la URL no puede bastar para ordenar un cambio en la cuenta.

Supongamos que changeEmail() contiene el fetch() del ejemplo anterior. Así se estaba llamando a esa función:

const email = new URLSearchParams(location.hash.slice(1)).get("email");
if (email) changeEmail(email, csrfToken);

El atacante controla email mediante el enlace y la SPA hace el resto, incluido añadir el token CSRF. El fallo no está en que el endpoint sea dinámico: /api/profile/email es fijo. El fallo está en ejecutar changeEmail() automáticamente al leer la URL.

Podemos conservar la misma función y llamarla solo cuando el usuario envíe el formulario de cambio de correo:

const form = document.querySelector("#email-form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const email = new FormData(form).get("email");
  if (typeof email !== "string") return;

  await changeEmail(email, csrfToken);
});

Ahora una URL manipulada no activa la operación por sí sola. El servidor debe seguir validando el correo y la autorización, como en cualquier petición.

Si la aplicación necesita seleccionar operaciones dinámicamente, una lista cerrada de acciones y destinos evita rutas arbitrarias, pero no basta en este caso: el atacante podría elegir una acción permitida. Las operaciones sensibles también deben pasar por el flujo de confirmación previsto.

Lo mismo vale para los mensajes recibidos mediante postMessage: hay que verificar su origen y su contenido, y evitar que un mensaje externo ordene operaciones autenticadas sin la intervención prevista del usuario.

La clave del Client-Side CSRF: los tokens CSRF, SameSite y las comprobaciones de origen no descubren por qué el JavaScript legítimo ha decidido enviar una petición. Esa decisión también hay que protegerla.

2.4. Utilizar tokens CSRF asociados a la sesión

Otra de las defensas recomendadas por OWASP es el patrón Synchronizer Token. El servidor genera un valor aleatorio e impredecible asociado a la sesión autenticada. El frontend lo envía explícitamente en las operaciones que modifican información, y el servidor comprueba que coincide con el esperado antes de ejecutar nada.

Vamos a corregir nuestro ejemplo de FastAPI. Supongamos que la aplicación ya dispone de un sistema de sesiones que genera un token al iniciar sesión:

import secrets

csrf_token = secrets.token_urlsafe(32)

Este token se guarda en el almacenamiento de sesiones del servidor y se asocia al usuario autenticado. Podemos ofrecer un endpoint para recuperarlo desde nuestra propia aplicación:

@app.get("/api/csrf-token")
def get_csrf_token(request: Request):
    session = get_authenticated_session(request)

    return {
        "csrfToken": session.csrf_token
    }

Y proteger la operación de cambio de correo electrónico:

import secrets
from typing import Annotated

from fastapi import FastAPI, Form, Header, HTTPException, Request

@app.post("/api/profile/email")
def change_email(
    request: Request,
    email: Annotated[str, Form()],
    csrf_token: Annotated[
        str | None,
        Header(alias="X-CSRF-Token")
    ] = None
):
    session = get_authenticated_session(request)

    if (
        not csrf_token
        or not secrets.compare_digest(
            csrf_token,
            session.csrf_token
        )
    ):
        raise HTTPException(
            status_code=403,
            detail="Invalid CSRF token"
        )

    update_user_email(
        user_id=session.user_id,
        email=email
    )

    return {"ok": True}

Ahora el servidor exige dos condiciones: una sesión autenticada válida y un token CSRF que coincida con el asociado a esa sesión.

Desde el frontend, enviaríamos la operación así (en una aplicación real obtendrías el token una vez y lo reutilizarías):

const response = await fetch("/api/csrf-token", {
  credentials: "same-origin"
});

if (!response.ok) {
  throw new Error("Cannot obtain CSRF token");
}

const { csrfToken } = await response.json();

const result = await fetch("/api/profile/email", {
  method: "POST",
  credentials: "same-origin",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    "X-CSRF-Token": csrfToken
  },
  body: new URLSearchParams({
    email: "new@example.com"
  })
});

¿Por qué frena esto un CSRF? Porque el atacante puede intentar provocar una petición desde malicioso.com, pero le falta una pieza:

  1. La cookie puede viajar sola. Si las reglas de SameSite y del navegador permiten enviarla, la petición llegará con la sesión de la víctima. Eso identifica al usuario, pero no demuestra que haya pedido el cambio.
  2. El token CSRF no viaja solo. La página atacante no puede leer la respuesta de /api/csrf-token desde otro origen si la aplicación no le da acceso mediante CORS. Tampoco puede adivinar un token generado al azar y asociado a esa sesión. Por tanto, no puede añadir el valor correcto a X-CSRF-Token.

El servidor comprueba ambas cosas antes de cambiar el correo: si recibe la cookie pero falta el token correcto, rechaza la operación. Esta explicación presupone que frontend y API comparten origen, que la sesión se gestiona de forma segura y que el token no se filtra por otra vía.

Las funciones de autenticación y persistencia se han omitido para centrarnos en CSRF. Para usar Form en FastAPI hay que instalar python-multipart. Y una implementación completa debe proteger todas las operaciones sensibles y gestionar bien la creación, renovación e invalidación de las sesiones.

2.5. Otras medidas complementarias

Algunas prácticas adicionales que merece la pena aplicar:

  • No usar GET, HEAD u otros métodos considerados seguros para operaciones que modifiquen el estado de la aplicación.
  • Aprovechar las protecciones CSRF de los frameworks cuando estén disponibles y sean adecuadas.
  • En arquitecturas sin almacenamiento de sesión en el servidor que necesiten tokens CSRF, usar el patrón Signed Double-Submit Cookie, vinculado criptográficamente a la sesión, en lugar de una comparación ingenua de valores.
  • Exigir confirmaciones adicionales en operaciones especialmente sensibles, como transferencias importantes o cambios de credenciales.
  • Prevenir XSS, porque el código malicioso ejecutado dentro de nuestra aplicación puede superar numerosas defensas CSRF.

La clave de CSRF: no basta con verificar que una petición está autenticada. Hay que impedir que un atacante provoque operaciones aprovechando credenciales automáticas o manipulando el JavaScript legítimo que construye las peticiones.

Para saber más

OWASP. Cross-Site Request Forgery Prevention Cheat Sheet.

PortSwigger Web Security Academy. Cross-Site Request Forgery (CSRF).

PortSwigger Web Security Academy. How to prevent CSRF vulnerabilities.

MDN Web Docs. Cross-Site Request Forgery.

MDN Web Docs. CORS y peticiones preflight.