Proteger Formularios Web Contra Ataques CSRF
Cómo prevenir ataques de Cross-Site Request Forgery usando tokens de sincronización, cookies SameSite y patrones de double-submit cookie.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Visión general
Cross-Site Request Forgery (CSRF) engaña a usuarios autenticados para que realicen acciones no deseadas en un sitio web en el que confían. Un atacante crea un enlace o formulario malicioso que, cuando el usuario logueado hace clic, envía un request al sitio víctima usando la cookie de sesión existente del usuario. El servidor ve un request legítimo de un usuario autenticado y ejecuta la acción — cambiar un email, transferir fondos o eliminar una cuenta — sin conocimiento del usuario.
A diferencia de XSS, que inyecta scripts maliciosos, CSRF explota el comportamiento automático del navegador de enviar cookies. Si bank.com tiene un endpoint POST /transfer, un atacante puede incrustar un formulario en evil.com que se envía a bank.com/transfer. Mientras el usuario tenga una cookie de sesión válida para bank.com, el navegador la envía automáticamente.
Cuándo usarlo
Usa esta receta cuando:
- Construyes aplicaciones web con endpoints que cambian estado (POST, PUT, DELETE, PATCH)
- Implementas configuraciones de cuenta, flujos de pago o paneles administrativos
- Auditando aplicaciones existentes por vulnerabilidades CSRF
- Eligiendo entre synchronizer tokens, double-submit cookies y protección SameSite-only
Solución
Synchronizer Token Pattern (Django/Python)
from django.middleware.csrf import get_token
def render_form(request):
context = {'csrf_token': get_token(request)}
return render(request, 'form.html', context)
# Template
<form method="post" action="/settings/">
{% csrf_token %}
<input type="email" name="email" />
<button type="submit">Actualizar</button>
</form>
Double-Submit Cookie (Node.js/Express)
const crypto = require('crypto');
function generateCsrfToken(req, res) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrfToken', token, { httpOnly: false, sameSite: 'strict' });
return token;
}
function validateCsrfToken(req, res, next) {
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (token !== req.cookies.csrfToken) {
return res.status(403).json({ error: 'Token CSRF inválido' });
}
next();
}
SameSite Cookie (Spring Boot)
@Configuration
public class CookieConfig implements WebMvcConfigurer {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setSameSite("Strict");
serializer.setUseSecureCookie(true);
return serializer;
}
}
Explicación
- Synchronizer tokens: El servidor genera un token aleatorio por sesión (o por request) y lo incrusta en cada formulario. El token se almacena server-side y se valida en el envío. Como
evil.comno puede leer el token del DOM o cookies debank.com, no puede forjar requests válidos. - Double-submit cookie: Un token aleatorio se configura como cookie y también se envía en un campo de formulario o header. El servidor verifica que ambos valores coinciden. Es stateless — no requiere almacenamiento server-side — pero depende de que el atacante no pueda leer la cookie.
- SameSite cookies: Configurar
SameSite=StrictoLaxen cookies de sesión previene que el navegador las envíe con requests cross-origin. Es la defensa más simple y confiable, pero no todos los navegadores y escenarios la soportan perfectamente.
Variantes
| Técnica | Almacenamiento server | Stateless | Dependencia de navegador |
|---|---|---|---|
| Synchronizer token | Sí (sesión) | No | Ninguna |
| Double-submit cookie | No | Sí | Ninguna |
| SameSite cookie | No | Sí | Navegadores modernos |
| Custom headers | No | Sí | Solo AJAX |
Lo que funciona
- Usa SameSite=Strict en cookies de sesión: esto solo bloquea la mayoría de ataques CSRF. Combínalo con tokens para defensa en profundidad.
- Rota tokens CSRF por sesión, no por request: los tokens por-request rompen el botón de atrás y los workflows multi-tab. Los tokens por sesión son seguros y usables.
- Valida tokens para todos los métodos que cambian estado: verifica protección CSRF en POST, PUT, PATCH y DELETE. Los métodos seguros (GET, HEAD) no deberían cambiar estado de todos modos.
- Incluye tokens en headers de AJAX: para SPAs, lee el token desde un meta tag o cookie y envíalo como header custom (
X-CSRF-Token). - Rechaza tokens faltantes con 403: no ignores silenciosamente tokens faltantes. Un 403 señala una mala configuración o un intento de ataque.
Errores comunes
- Confiar solo en SameSite sin tokens: navegadores más antiguos y ciertos patrones de navegación cross-site pueden no enforce SameSite. Los tokens proveen una defensa de respaldo.
- No proteger formularios de login: login CSRF es real. Un atacante puede forzar a una víctima a loguearse en una cuenta controlada por el atacante, habilitando ataques subsecuentes.
- Usar GET para acciones que cambian estado:
GET /delete-account?id=123es trivialmente explotable vía una image tag o enlace. Siempre usa POST, PUT, DELETE para mutaciones. - Almacenar tokens en localStorage: XSS puede robar localStorage. Almacena el token server-side en un campo de formulario oculto o una cookie non-HttpOnly (para el patrón double-submit).
Preguntas frecuentes
P: ¿El CSRF sigue siendo relevante con SameSite cookies? R: Sí. SameSite bloquea la mayoría de CSRF pero no todos los escenarios (requests GET cross-site, iframes incrustados, endpoints que aceptan form data). La defensa en profundidad con tokens es recomendada.
P: ¿Las APIs necesitan protección CSRF? R: Las APIs que aceptan submissions de formulario o usan autenticación por cookie necesitan protección CSRF. Las APIs que usan bearer tokens o API keys en headers son generalmente inmunes porque el atacante no puede forjar el header. Para seguridad de headers de API, consulta headers de seguridad API.
P: ¿Qué es login CSRF? R: Un atacante engaña a una víctima para que se loguee en un sitio bajo la cuenta del atacante. La víctima entonces realiza acciones (agregar métodos de pago, escribir reviews) que benefician al atacante.
P: ¿Puedo usar un token CSRF estático para todos los usuarios? R: No. Los tokens estáticos son triviales de extraer y reutilizar. Los tokens deben ser únicos por sesión de usuario e impredecibles.
¿Esta solución está lista para producción?
Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.
¿Cuáles son las características de rendimiento?
El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.
¿Cómo depuro problemas con este enfoque?
Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.
Soluciones Avanzadas
Double-submit cookie firmada (stateless, resistente a XSS)
El double-submit cookie básico es vulnerable si un atacante puede setear una cookie en el navegador de la víctima (ej. vía un subdominio). Firmar la cookie con un secreto server-side previene esto:
const crypto = require('crypto');
const CSRF_SECRET = process.env.CSRF_SECRET || 'rotate-this-secret';
function generateSignedCsrfToken(req, res) {
const token = crypto.randomBytes(32).toString('base64url');
const signature = crypto
.createHmac('sha256', CSRF_SECRET)
.update(token)
.digest('base64url');
const signedToken = `${token}.${signature}`;
res.cookie('csrfToken', signedToken, {
httpOnly: false,
sameSite: 'strict',
secure: true,
path: '/',
});
return signedToken;
}
function validateSignedCsrfToken(req, res, next) {
const headerToken = req.headers['x-csrf-token'];
const cookieToken = req.cookies.csrfToken;
if (!headerToken || !cookieToken) {
return res.status(403).json({ error: 'Falta token CSRF' });
}
if (headerToken !== cookieToken) {
return res.status(403).json({ error: 'Token CSRF no coincide' });
}
// Verificar firma
const [token, signature] = cookieToken.split('.');
if (!token || !signature) {
return res.status(403).json({ error: 'Formato de token CSRF inválido' });
}
const expectedSig = crypto
.createHmac('sha256', CSRF_SECRET)
.update(token)
.digest('base64url');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
)) {
return res.status(403).json({ error: 'Firma de token CSRF inválida' });
}
next();
}
// Setup Express
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
// Generar token en GET (renderizar página de formulario)
app.get('/form', (req, res) => {
const token = generateSignedCsrfToken(req, res);
res.json({ csrfToken: token });
});
// Validar token en POST (enviar formulario)
app.post('/submit', validateSignedCsrfToken, (req, res) => {
res.json({ success: true });
});
Protección CSRF para SPAs (React + interceptor fetch)
Las single-page applications necesitan tokens CSRF accesibles desde JavaScript. Usa una cookie non-HttpOnly y un meta tag o endpoint de API:
// Utilidad CSRF en React — obtener token de API al iniciar la app
let csrfToken = null;
export async function initCsrf() {
const res = await fetch('/api/csrf-token', {
credentials: 'same-origin',
});
const data = await res.json();
csrfToken = data.token;
return csrfToken;
}
// Interceptor fetch: adjuntar token a todos los requests que cambian estado
export function csrfFetch(url, options = {}) {
const method = (options.method || 'GET').toUpperCase();
// Solo adjuntar token CSRF a métodos que cambian estado
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
options.headers = {
...options.headers,
'X-CSRF-Token': csrfToken,
};
}
return fetch(url, {
...options,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
}
// Uso en un componente React
import { useState, useEffect } from 'react';
function SettingsForm() {
const [email, setEmail] = useState('');
useEffect(() => {
initCsrf();
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
const res = await csrfFetch('/api/settings', {
method: 'POST',
body: JSON.stringify({ email }),
});
if (res.ok) {
alert('Configuración actualizada');
} else {
alert('Error al actualizar configuración');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit">Actualizar</button>
</form>
);
}
Validación de headers Origin y Referer
Como capa adicional, valida que el header Origin o Referer coincida con tu sitio:
from django.http import HttpResponseForbidden
from urllib.parse import urlparse
ALLOWED_ORIGINS = {'https://myapp.com', 'https://www.myapp.com'}
def validate_origin(get_response):
"""Middleware que valida Origin/Referer en requests que cambian estado."""
def middleware(request):
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
origin = request.headers.get('Origin')
referer = request.headers.get('Referer')
source = origin or referer
if not source:
return HttpResponseForbidden('Falta header Origin')
parsed = urlparse(source)
if f"{parsed.scheme}://{parsed.netloc}" not in ALLOWED_ORIGINS:
return HttpResponseForbidden('Origin inválido')
return get_response(request)
return middleware
Rotación de token por request con almacenamiento en sesión
Para aplicaciones de alta seguridad, rota tokens por request en lugar de por sesión:
import secrets
from flask import Flask, session, request, jsonify, abort
app = Flask(__name__)
app.secret_key = 'rotate-this-secret'
@app.before_request
def generate_csrf_token():
if request.method == 'GET':
session['csrf_token'] = secrets.token_urlsafe(32)
@app.route('/form')
def form_page():
token = session.get('csrf_token')
return jsonify({'csrf_token': token})
@app.route('/submit', methods=['POST'])
def submit():
token = request.headers.get('X-CSRF-Token', '')
session_token = session.get('csrf_token', '')
if not token or not session_token:
abort(403, description='Falta token CSRF')
if not secrets.compare_digest(token, session_token):
abort(403, description='Token CSRF inválido')
# Rotar token para el próximo request
session['csrf_token'] = secrets.token_urlsafe(32)
return jsonify({'success': True, 'next_token': session['csrf_token']})
Mejores Prácticas Adicionales
- Usa el prefijo
__Host-en cookies. El prefijo__Host-fuerzaSecure,Path=/y sin atributoDomain, previniendo inyección de cookies desde subdominios:
res.cookie('__Host-csrfToken', signedToken, {
httpOnly: false,
sameSite: 'strict',
secure: true,
path: '/',
// Sin atributo domain — el prefijo __Host- lo prohíbe
});
- Audita la protección CSRF con tests automatizados. Escribe tests que verifiquen que los tokens son requeridos y rechazados:
import pytest
from django.test import Client
def test_csrf_token_required():
"""POST sin token CSRF debería ser rechazado."""
client = Client(enforce_csrf_checks=True)
response = client.post('/api/settings', {'email': 'test@example.com'})
assert response.status_code == 403
def test_csrf_token_accepted():
"""POST con token CSRF válido debería exitosar."""
client = Client()
# El test client de Django maneja CSRF automáticamente con enforce_csrf_checks=False
response = client.post('/api/settings', {'email': 'test@example.com'})
assert response.status_code == 200
Errores Comunes Adicionales
- Excluir webhooks de la protección CSRF incorrectamente. Los webhooks de terceros (Stripe, GitHub) deberían usar firmas HMAC, no tokens CSRF. Crea una exención separada:
// INCORRECTO: deshabilitar CSRF globalmente para rutas API
app.use('/api', csrf({ ignore: true }));
// CORRECTO: eximir solo rutas de webhook con verificación HMAC
app.post('/api/webhooks/stripe',
// Saltar CSRF, verificar firma HMAC en su lugar
skipCsrf,
verifyStripeSignature,
handleWebhook
);
function skipCsrf(req, res, next) {
req.csrfToken = () => ''; // Bypass token check
next();
}
- No configurar
SameSiteen la cookie CSRF misma. Si la cookie CSRF tieneSameSite=None, un sitio atacante puede disparar un request que la incluya, haciendo el patrón double-submit inefectivo:
// INCORRECTO: SameSite=None permite envío cross-site de cookies
res.cookie('csrfToken', token, { sameSite: 'none', secure: true });
// CORRECTO: SameSite=Strict previene envío cross-site
res.cookie('csrfToken', token, { sameSite: 'strict', secure: true });
Preguntas Frecuentes Adicionales
¿Cómo manejo CSRF con CORS?
CSRF y CORS son concerns separados. CORS controla qué orígenes pueden leer respuestas; la protección CSRF controla qué orígenes pueden enviar requests que cambian estado. Incluso con CORS estricto, CSRF es posible porque las submissions de formulario no están sujetas a CORS preflight. Siempre implementa tokens CSRF independientemente de la configuración CORS.
¿Debo usar SameSite=Strict o SameSite=Lax?
Usa Strict para cookies de sesión si los usuarios no necesitan navegar desde enlaces externos mientras están logueados. Usa Lax si necesitas que los enlaces externos funcionen (ej. clic en un enlace en un email hacia tu app). Lax aún bloquea POST cross-site, que cubre la mayoría de los vectores CSRF.
¿Pueden cachearse los tokens CSRF?
No. Los synchronizer tokens son específicos de sesión y no deben cachearse. Si usas un CDN, excluye las páginas con tokens CSRF del caching, o usa el patrón double-submit donde el token está en una cookie (no cacheada con la página).
Recursos Relacionados
Secure APIs with HTTP Security Headers
How to configure essential security headers like HSTS, CSP, and X-Frame-Options to protect APIs and web applications from common attacks.
RecipeImplement Secure Session Management
How to create, validate, and expire user sessions securely across web applications using cookies, tokens, and server-side storage.
RecipePrevent Cross-Site Scripting (XSS)
How to sanitize user input, escape output, and use Content Security Policy to prevent XSS attacks in web applications.
RecipePassword Hashing in Production
Securely hash and verify passwords using bcrypt, scrypt, and Argon2 with what works.
RecipeImplement Encryption at Rest for Databases and File Storage
How to encrypt sensitive data before storing it in databases, object storage, and backups using AES-256-GCM, envelope encryption, and key management services.
RecipeHMAC Request Signing
Secure API requests with HMAC-SHA256 signatures to ensure integrity and authenticity.
RecipeImplement Rate Limiting for APIs and Web Applications
How to protect APIs and web endpoints from abuse using token bucket, sliding window, and fixed window rate limiting strategies with Redis and in-memory implementations.