CSRF (Cross-Site Request Forgery) es un ataque en el que una página web maliciosa hace que el navegador de un usuario autenticado envíe peticiones a tu aplicación sin que el usuario lo sepa. La defensa estándar son los tokens de formulario: valores aleatorios que el atacante no puede predecir y que la aplicación verifica antes de procesar cualquier acción.
Cómo funciona el ataque
El usuario está autenticado en banco.ejemplo.com. Visita una página maliciosa que contiene:
<!-- En la web del atacante -->
<form action="https://banco.ejemplo.com/transferir" method="POST" id="form">
<input type="hidden" name="cantidad" value="1000">
<input type="hidden" name="destino" value="cuenta_atacante">
</form>
<script>document.getElementById('form').submit();</script>
El navegador envía la petición con las cookies de sesión del usuario y el banco la procesa. Sin tokens CSRF, la transferencia se ejecuta.
Tokens CSRF: la defensa principal
<?php
session_start();
// Generar un token único por sesión
function generarTokenCSRF(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Validar el token de la petición
function validarTokenCSRF(string $token): bool {
if (empty($_SESSION['csrf_token'])) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
// En el formulario HTML:
$token = generarTokenCSRF();
echo '<form method="POST" action="/transferir">
<input type="hidden" name="csrf_token" value="' . htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">
<input type="number" name="cantidad" min="1">
<button type="submit">Transferir</button>
</form>';
// En el handler POST:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token_recibido = $_POST['csrf_token'] ?? '';
if (!validarTokenCSRF($token_recibido)) {
http_response_code(403);
exit('Token CSRF inválido');
}
// Procesar la transferencia...
}
Por qué hash_equals() y no ===
La comparación directa === es vulnerable a ataques de temporización: el tiempo que tarda varía según cuántos caracteres coinciden, lo que permite a un atacante sofisticado adivinar el token carácter a carácter. hash_equals() siempre tarda el mismo tiempo independientemente de cuántos caracteres coincidan:
<?php
// MAL vulnerable a timing attacks
if ($_POST['csrf_token'] === $_SESSION['csrf_token']) { ... }
// BIEN tiempo constante
if (hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) { ... }
Tokens por formulario (más seguros que por sesión)
Un token único por sesión funciona, pero si usas un token diferente por acción/formulario, un token comprometido solo afecta a esa acción concreta:
<?php
function tokenAccion(string $accion): string {
session_start();
$secreto = $_SESSION['csrf_secret'] ??= bin2hex(random_bytes(32));
// HMAC del nombre de la acción con el secreto de sesión
return hash_hmac('sha256', $accion, $secreto);
}
function validarAccion(string $accion, string $token): bool {
return hash_equals(tokenAccion($accion), $token);
}
// En el formulario de borrado:
$token = tokenAccion('borrar_cuenta_42');
echo '<form method="POST">
<input type="hidden" name="accion" value="borrar_cuenta_42">
<input type="hidden" name="csrf" value="' . $token . '">
<button type="submit">Borrar cuenta</button>
</form>';
// En el handler:
if (!validarAccion($_POST['accion'] ?? '', $_POST['csrf'] ?? '')) {
die('Token inválido');
}
Cookies SameSite: protección adicional
La directiva SameSite en las cookies evita que el navegador las envíe en peticiones cross-site. Es una segunda capa de defensa, no un sustituto del token:
<?php
// PHP 7.3+ SameSite en session cookie
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true, // solo HTTPS
'httponly' => true, // no accesible desde JavaScript
'samesite' => 'Strict', // o 'Lax' para compatibilidad con OAuth/links externos
]);
session_start();
// Para cookies propias:
setcookie('auth_token', $valor, [
'expires' => time() + 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
Con SameSite=Strict, la cookie no se envía ni al seguir un enlace externo a tu sitio. SameSite=Lax es menos restrictivo: permite las peticiones GET desde enlaces externos, pero bloquea los POST cross-site.
Patrón double submit cookie para APIs AJAX
Las APIs stateless que no usan sesiones PHP pueden implementar la variante double-submit cookie: la misma clave aleatoria se envía como cookie y como cabecera HTTP. El servidor las compara:
<?php
// Generar cookie CSRF al cargar la SPA
if (!isset($_COOKIE['XSRF-TOKEN'])) {
$token = bin2hex(random_bytes(32));
setcookie('XSRF-TOKEN', $token, [
'path' => '/',
'secure' => true,
'samesite' => 'Strict',
'httponly' => false, // JavaScript necesita leerla para enviarla
]);
}
// Validar en el API endpoint
function validarXSRF(): bool {
$cookie = $_COOKIE['XSRF-TOKEN'] ?? '';
$cabecera = $_SERVER['HTTP_X_XSRF_TOKEN'] ?? '';
return $cookie !== '' && hash_equals($cookie, $cabecera);
}
// El cliente JS (axios lo hace automáticamente):
// axios.defaults.headers.common['X-XSRF-TOKEN'] = getCookie('XSRF-TOKEN');
Verificación del header Origin como segunda capa
<?php
function verificarOrigen(string ...$origenes_permitidos): bool {
$origen = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '';
if (empty($origen)) {
// Sin Origin/Referer: rechazar o permitir según política
return false;
}
$host = parse_url($origen, PHP_URL_HOST);
return in_array($host, $origenes_permitidos, true);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verificarOrigen('miaplicacion.com', 'www.miaplicacion.com')) {
http_response_code(403);
exit;
}
// Además verificar el token CSRF
if (!validarTokenCSRF($_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit;
}
}
Clase completa de protección CSRF
<?php
class CSRF {
public function __construct(private string $campo = 'csrf_token') {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
public function token(): string {
if (empty($_SESSION[$this->campo])) {
$_SESSION[$this->campo] = bin2hex(random_bytes(32));
}
return $_SESSION[$this->campo];
}
public function campo(): string {
return '<input type="hidden" name="' . $this->campo . '" value="'
. htmlspecialchars($this->token(), ENT_QUOTES, 'UTF-8') . '">';
}
public function verificar(): bool {
$token = $_POST[$this->campo] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
return !empty($_SESSION[$this->campo]) && hash_equals($_SESSION[$this->campo], $token);
}
public function verificarOAbortar(): void {
if (!$this->verificar()) {
http_response_code(419);
exit('CSRF token mismatch');
}
}
}
// En el formulario:
$csrf = new CSRF();
echo '<form method="POST">' . $csrf->campo() . '...</form>';
// En el handler:
(new CSRF())->verificarOAbortar();
La protección CSRF correcta se implementa con tres capas: tokens aleatorios verificados con hash_equals(), cookies SameSite=Strict o Lax y verificación del header Origin como respaldo. Cualquiera de las tres sola es suficiente contra la mayoría de ataques; la combinación hace prácticamente imposible un CSRF exitoso.
