Prevenir CSRF en PHP: tokens de formulario, validación y cookies SameSite

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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP