Imagina este escenario: un usuario inicia sesión en el sistema de cobro para pagar. Deja la pestaña abierta y, sin cerrar sesión, entra a revisar su correo en otra pestaña.
Ahí abre un enlace que le enviaron por WhatsApp. Esa página, con apariencia inofensiva, contiene un formulario invisible que se envía solo, apuntando directamente al sistema de pagos que el usuario todavía tiene abierto.

El navegador, fiel a su trabajo, envía esa petición junto con las cookies de sesión válidas.
El servidor la recibe y, si no tiene ninguna protección adicional, la procesa como si el usuario la hubiera enviado a propósito.
Esto es un ataque CSRF (Cross-Site Request Forgery, o falsificación de petición entre sitios) — y es uno de los primeros controles que reviso antes de poner en producción cualquier sistema que mueva dinero o datos sensibles.
Este artículo forma parte de mi serie de seguridad en PHP puro, sin depender de ningún framework.
Por qué el login no es suficiente
Es un error común pensar que la sesión iniciada ya protege al usuario. En realidad, es justo lo contrario: el ataque CSRF depende de que el usuario esté logueado.
El navegador no distingue entre una petición que el usuario envió a propósito desde tu formulario y una que un sitio malicioso disparó en su nombre — ambas viajan con las mismas cookies de sesión.
Por eso, la validación no puede basarse solo en “¿esta sesión es válida?”. Necesita responder una segunda pregunta: ¿esta petición realmente vino del formulario que yo generé?
Los 6 elementos de una protección CSRF real
1. Generar un token criptográficamente seguro
Todo empieza con un valor aleatorio, único e imposible de adivinar, generado por el servidor:
function generar_csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}random_bytes() usa el generador criptográficamente seguro del sistema operativo — nada de rand() ni uniqid(), que sí pueden llegar a predecirse.
2. Guardarlo en la sesión, no solo en el HTML
El token vive del lado del servidor, en $_SESSION. El formulario solo recibe una copia.
Así, un atacante que no tenga acceso a la sesión del usuario no tiene forma de generar un token que coincida con el que el servidor espera.
3. Insertarlo como campo oculto en cada formulario
Cada formulario que modifique datos o dinero necesita su copia del token, no solo el de login:
<form method="POST" action="/procesar-pago.php">
<input type="hidden" name="csrf_token"
value="<?= htmlspecialchars(generar_csrf_token()) ?>">
<!-- resto de los campos del formulario -->
<button type="submit">Confirmar pago</button>
</form>Este es el punto donde más he visto fallar a sistemas reales: protegen el login, pero olvidan formularios “secundarios” como cambiar una contraseña, agregar un usuario administrador o modificar una cuenta bancaria de destino — que suelen ser justo los que más interesan a un atacante.
4. Validar con una comparación segura contra tiempo
Al recibir el formulario, el servidor compara el token recibido con el que guardó en sesión:
function validar_csrf_token(?string $token): bool
{
if (empty($_SESSION['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!validar_csrf_token($_POST['csrf_token'] ?? null)) {
http_response_code(403);
die('Solicitud no válida. Por favor recarga la página e intenta de nuevo.');
}
// el token es correcto, se procesa el pago...
unset($_SESSION['csrf_token']);
}Uso hash_equals() y no == a propósito: una comparación normal de cadenas se detiene en el primer carácter distinto, lo que en teoría permite a un atacante deducir el token carácter por carácter midiendo el tiempo de respuesta. hash_equals() compara siempre en tiempo constante, sin importar en qué carácter difieren los valores.
5. Regenerar el token después de usarlo
Para formularios sensibles — transferencias, pagos, cambios de credenciales — regenero el token cada vez que se usa (unset() en el ejemplo de arriba, y se vuelve a generar en la siguiente carga de página).
Esto convierte al token en uno de un solo uso: aunque alguien intercepte esa petición ya enviada, no puede reutilizarla.
Para formularios menos críticos, un token por sesión (que dure mientras el usuario esté logueado) suele ser suficiente y evita fricciones si el usuario tiene el formulario abierto en dos pestañas.
6. Proteger también las peticiones AJAX
Un sistema moderno no solo envía formularios tradicionales — también dispara peticiones desde JavaScript. Esas peticiones necesitan la misma protección, normalmente vía un header personalizado:
fetch('/api/procesar-pago', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(datos)
});Codigo PHP
$headers = getallheaders();
$token = $headers['X-CSRF-Token'] ?? null;
if (!validar_csrf_token($token)) {
http_response_code(403);
echo json_encode(['error' => 'Token CSRF inválido']);
exit;
}La diferencia entre protección real y placebo
He revisado suficiente código como para reconocer el patrón de una protección CSRF que solo existe en apariencia:
- Comparar tokens con
==en vez dehash_equals()— funciona en las pruebas, pero deja una puerta de timing attack abierta. - Proteger solo el login y dejar sin token cualquier formulario que “no parece tan importante” — hasta que ese formulario resulta ser el que cambia el correo de recuperación de contraseña.
- Enviar el token por GET en vez de POST — queda expuesto en el historial del navegador, en los logs del servidor y en el header
Refererde cualquier enlace que el usuario haga clic después. - Confiar únicamente en la cookie de sesión para validar la petición, sin un segundo valor independiente que confirme que la petición nació en tu formulario.
Como capa adicional — nunca como sustituto de lo anterior — vale la pena configurar las cookies de sesión con SameSite=Lax o SameSite=Strict. Esto le indica al navegador que no envíe la cookie en peticiones que se originan desde otro sitio, lo cual reduce buena parte de la superficie de ataque incluso antes de que el token entre en juego.
Por qué esto no es opcional
En los sistemas que he construido para el cobro de servicios públicos, trámites de gobierno y pagos institucionales, esta no es una protección “recomendable” — es una condición para salir a producción.
Con estos 6 elementos, cualquier sistema en PHP puro queda cubierto sin necesitar una sola librería externa — solo funciones nativas del lenguaje, bien aplicadas.
Descargar
Para no quedarnos solo en la teoría, preparé un ejemplo completo y funcional que puedes descargar y correr en tu propia máquina. Incluye dos versiones del mismo formulario de transferencia — una vulnerable y otra protegida — más dos páginas que simulan el ataque de un sitio malicioso contra cada una.
⬇ Descargar el ejemplo completo (csrf-demo-php.zip)
Qué incluye
csrf-demo-php/ ├── inseguro/ → formulario de transferencia SIN protección CSRF ├── seguro/ → el mismo formulario, CON protección CSRF └── ataque/ → dos páginas HTML que simulan el sitio de un atacante
Cómo probarlo (resumen — el README incluye el detalle completo)
- Requisito único: tener PHP 8.0+ instalado. Sin base de datos, sin Composer.
- Descomprime el zip y, desde la carpeta
csrf-demo-php/, copiar el la carpeta a la c:/xampp/htdocs/
- Abre
http://localhost//csrf-demo-phpinseguro/index.php— verás un saldo de $1,000. Ahora abre el archivo/csrf-demo-phpataque/ataca-version-insegura.htmldirectamente en el navegador (doble clic). Esa página simula un sitio malicioso que, en cuanto carga, envía una transferencia de $900 en tu nombre — sin que hagas clic en nada. Recarga la versión insegura y confirma: tu saldo bajó sin que tú lo autorizaras. - Repite lo mismo contra
http://localhost//csrf-demo-phpseguro/index.phpusandoataque/ataca-version-segura.html. Esta vez el ataque es rechazado con un error 403, porque no tiene el token que solo existe en tu sesión real. - Agrega
?reset=1a cualquiera de las dos URL para reiniciar el saldo y repetir la prueba las veces que quieras.
Nota de seguridad: la carpeta inseguro/ existe únicamente con fines educativos para tu entorno local — nunca la subas a un servidor con acceso público.
¿Tienes dudas sobre cómo aplicar esto a un caso específico de tu sistema? Déjamelo en los comentarios.