Estrategias de protección contra Cross-Site Request Forgery
Guía completa para entender y prevenir ataques CSRF: protección por tokens, cookies SameSite, patrón double-submit e implementaciones por framework.

Cross-Site Request Forgery (CSRF) explota la confianza que el navegador deposita en las cookies. Cuando un usuario está autenticado en tu sitio, su navegador envía automáticamente las cookies con cada petición, incluso con las peticiones disparadas por una página maliciosa de un tercero. CSRF engaña al navegador para que realice peticiones autenticadas que el usuario nunca pretendió hacer.
A pesar de ser un ataque bien conocido, las vulnerabilidades CSRF siguen siendo comunes porque los desarrolladores asumen que los frameworks modernos lo gestionan automáticamente. Algunos lo hacen. Muchos no, especialmente en arquitecturas de SPA más API.
Cómo funciona CSRF
El ataque requiere tres condiciones: el usuario está autenticado (hay cookies presentes), la acción objetivo usa cookies para la autenticación, y la petición es lo bastante "simple" para que el navegador la envíe sin una comprobación preflight.
<!-- ❌ Malicious page hosted on attacker.com -->
<!-- User visits this page while logged into bank.com -->
<!-- Hidden form auto-submits on page load -->
<form id="evil" action="https://bank.com/api/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>
document.getElementById('evil').submit();
</script>
<!-- Browser sends bank.com cookies automatically.
bank.com sees a valid authenticated request
and processes the transfer. -->El usuario nunca hizo clic en un botón de transferencia. Solo visitó una página. El navegador hizo el resto porque las cookies no distinguen entre peticiones iniciadas por el usuario y peticiones disparadas por una página maliciosa.
Patrón de token sincronizador
La defensa CSRF más común: generar un token aleatorio en el servidor, incrustarlo en la página y validarlo en cada petición que modifique el estado.
import crypto from 'crypto';
// Generate a CSRF token and store it in the session
function generateCsrfToken(session: Session): string {
const token = crypto.randomBytes(32).toString('hex');
session.csrfToken = token;
return token;
}
// Middleware: validate token on state-changing requests
function csrfProtection(req: Request, res: Response, next: NextFunction) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(); // Safe methods don't need CSRF protection
}
const sessionToken = req.session?.csrfToken;
const requestToken =
req.headers['x-csrf-token'] ??
req.body?._csrf;
if (!sessionToken || !requestToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison prevents timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(sessionToken),
Buffer.from(requestToken as string)
);
if (!isValid) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
next();
}<!-- Embed the token in forms -->
<form action="/api/transfer" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}" />
<input type="text" name="to" placeholder="Recipient" />
<input type="number" name="amount" placeholder="Amount" />
<button type="submit">Transfer</button>
</form>El atacante no puede leer el token CSRF porque la política de mismo origen impide leer páginas de otro origen. Puede enviar un formulario a tu dominio, pero no puede incluir el token que no posee.
Patrón de cookie de doble envío (double submit)
Para APIs sin estado que no usan sesiones en el servidor, el patrón double submit usa un par cookie-cabecera en lugar de un token almacenado en la sesión.
// ❌ Stateless API with no CSRF protection
app.post('/api/settings', authenticate, (req, res) => {
// Authenticates via cookie — vulnerable to CSRF
updateSettings(req.user.id, req.body);
res.json({ success: true });
});// ✅ Double submit cookie pattern
import crypto from 'crypto';
// On login: set a CSRF cookie (not HttpOnly — JS must read it)
function setCsrfCookie(res: Response): void {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', token, {
sameSite: 'strict',
secure: true,
httpOnly: false, // JavaScript must read this cookie
path: '/',
});
}
// Middleware: compare cookie value with header value
function doubleSubmitCsrf(req: Request, res: Response, next: NextFunction) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
const isValid = crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken as string)
);
if (!isValid) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
next();
}
// Client-side: read cookie and send as header
async function apiRequest(url: string, data: unknown) {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken ?? '',
},
credentials: 'include',
body: JSON.stringify(data),
});
}El atacante puede disparar una petición que envíe la cookie, pero no puede leer su valor (política de mismo origen) y por tanto no puede establecer la cabecera correspondiente. El servidor rechaza las peticiones en las que la cookie y la cabecera no coinciden.
Atributo de cookie SameSite
Los navegadores modernos soportan el atributo de cookie SameSite, que impide que el navegador envíe cookies con peticiones de origen cruzado.
// ❌ Cookie without SameSite — sent on all requests including cross-origin
res.cookie('session', sessionId, {
httpOnly: true,
secure: true,
});
// ✅ SameSite=Strict — cookie only sent on same-origin navigation
res.cookie('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
// ✅ SameSite=Lax — sent on same-origin + top-level GET navigations
res.cookie('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax', // Good default — allows "Login with Google" flows
});SameSite=Lax es el valor por defecto en los navegadores modernos y previene CSRF en las peticiones POST. Strict ofrece una protección más fuerte pero rompe flujos legítimos de navegación entre sitios (hacer clic en un enlace a tu sitio desde un correo no incluirá la cookie de sesión).
SameSite es una defensa en profundidad. No confíes solo en ella: los navegadores antiguos no la soportan, y Lax aún permite CSRF basado en GET para endpoints que cambian el estado con GET (algo que no debería existir, pero que a veces existe).
Requisito de cabecera personalizada
Para APIs consumidas únicamente por JavaScript (no por envíos de formularios), exigir una cabecera personalizada que los navegadores no envían automáticamente es una defensa sencilla.
// Middleware: require a custom header on all requests
function requireCustomHeader(req: Request, res: Response, next: NextFunction) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// Browsers don't add X-Requested-With to form submissions
// Only JavaScript can set custom headers (triggers CORS preflight)
if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
return res.status(403).json({ error: 'Missing required header' });
}
next();
}
// Client-side: add the header to all requests
const api = {
post: (url: string, data: unknown) =>
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'include',
body: JSON.stringify(data),
}),
};Esto funciona porque un envío de formulario de origen cruzado no puede establecer cabeceras personalizadas. Establecer Content-Type: application/json por sí solo dispara un preflight de CORS, que bloquea la petición a menos que el servidor permita explícitamente el origen. Sin embargo, esta defensa requiere una configuración de CORS correcta: si permites todos los orígenes con Access-Control-Allow-Origin: *, esta protección se debilita.
Defensa en profundidad
Ninguna defensa CSRF por sí sola es suficiente. Combina varias estrategias en capas.
// Production CSRF configuration — multiple layers
const csrfConfig = {
// Layer 1: SameSite cookies (browser-level)
cookieOptions: {
sameSite: 'lax' as const,
secure: true,
httpOnly: true,
},
// Layer 2: CSRF token validation (application-level)
tokenValidation: true,
// Layer 3: Origin/Referer header check
originCheck: true,
// Layer 4: Custom header requirement for API routes
customHeaderRequired: true,
};
function fullCsrfProtection(req: Request, res: Response, next: NextFunction) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// Check Origin header
const origin = req.headers.origin ?? req.headers.referer;
if (origin) {
const allowedOrigins = [process.env.APP_URL];
const requestOrigin = new URL(origin).origin;
if (!allowedOrigins.includes(requestOrigin)) {
return res.status(403).json({ error: 'Origin not allowed' });
}
}
// Validate CSRF token (synchronizer or double-submit)
// ... token validation logic ...
next();
}Puntos clave
- CSRF explota la inclusión automática de cookies — los navegadores envían cookies con peticiones de origen cruzado por defecto
- Usa cookies SameSite=Lax como defensa base — previene CSRF en POST pero permite la navegación normal
- Implementa protección basada en tokens para aplicaciones con sesiones — tokens sincronizadores o cookies de doble envío
- Exige cabeceras personalizadas en los endpoints de la API — los navegadores no añaden cabeceras personalizadas a los envíos de formularios
- Usa comparación en tiempo constante para validar tokens —
crypto.timingSafeEqualpreviene los ataques de temporización - Combina defensas en capas — cookies SameSite + validación de tokens + comprobación de origen juntas proporcionan una protección robusta


