PHP CSRF e Segurança de Formulários

A Ana já protege a lojinha contra scripts injetados (XSS) e contra SQL malicioso (prepared statements). Falta um ataque mais sorrateiro, em que o golpista nem chega a tocar no site dela: ele faz o navegador da própria vítima enviar um formulário. Esse ataque se chama CSRF (Cross-Site Request Forgery, "falsificação de requisição entre sites"). Nesta aula você vai ver o ataque acontecer de verdade e protegê-lo com três peças: um token, um campo oculto e o hash_equals.

Você vai aprender
  • Como funciona um ataque CSRF, passo a passo, e por que estar logado é o que o torna possível.
  • Gerar um token secreto com random_bytes e guardá-lo na sessão.
  • Colocar o token num campo oculto do formulário e conferi-lo com hash_equals.
  • O que o atributo SameSite dos cookies faz, e por que ele ajuda mas não substitui o token.
  • As regras de ouro: nunca mudar dados com GET, token em todo formulário que altera algo.

A ideia: o bilhete com a assinatura certa

Imagine que o banco da Ana aceita qualquer bilhete que chegue com a assinatura dela. Um golpista não consegue falsificar a assinatura, mas descobre um truque: ele escreve "transfiram R$ 500 para o golpista" num papel e deixa em cima da mesa da Ana, no meio de outros papéis que ela vai assinar sem ler. A assinatura é verdadeira. O pedido, não.

Na web, a "assinatura" é o cookie de sessão. Depois que a Ana faz login na lojinha, o navegador dela manda esse cookie automaticamente em toda requisição para a lojinha, não importa de onde a requisição partiu. Se um site do golpista fizer o navegador da Ana enviar um formulário para a lojinha, o cookie vai junto, e a lojinha acha que foi a Ana.

Navegador da Ana guarda o cookie PHPSESSID=6ceb8c… Lojinha (localhost:8000) 1. a Ana faz login Site do golpista formulário escondido Lojinha "é a Ana!" → troca o e-mail cookie de sessão 2. a Ana abre o site 3. POST alterar-email.php + cookie (automático!)
O golpista nunca vê o cookie. Ele só faz o navegador da Ana enviar o pedido, e o navegador anexa o cookie sozinho.
  1. A Ana faz login na lojinha. O PHP cria a sessão e o navegador guarda o cookie PHPSESSID.
  2. Em outra aba, sem sair da lojinha, a Ana abre um link "imperdível" que recebeu. É o site do golpista.
  3. A página do golpista tem um formulário invisível que se envia sozinho para localhost:8000/alterar-email.php. O navegador manda o cookie da lojinha junto. A lojinha confere a sessão, vê "usuária ana" e troca o e-mail da conta para o do golpista. Depois é só ele pedir "esqueci minha senha".

Repare: o golpista não roubou senha nem cookie, e o site da lojinha não tem nenhum bug de XSS ou SQL. O problema é que a lojinha não sabe de onde veio o formulário.


O ataque, de verdade

Este é o alterar-email.php da lojinha, ainda sem proteção. Ele confere se há alguém logado e troca o e-mail:

<?php
session_start();

if (!isset($_SESSION['usuario'])) {
    http_response_code(401);
    exit("Faça login primeiro\n");
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $_SESSION['email'] = $_POST['email'];
    echo "E-mail alterado para {$_SESSION['email']}\n";
}

E esta é a página do golpista, hospedada em outro site qualquer:

<!doctype html>
<html>
<body onload="document.forms[0].submit()">
  <h1>Parabéns! Você ganhou um cupom!</h1>
  <form action="http://localhost:8000/alterar-email.php" method="post" style="display:none">
    <input name="email" value="golpista@exemplo.com">
  </form>
</body>
</html>
action="http://localhost:8000/alterar-email.php"
O formulário aponta para a lojinha, não para o site do golpista. Qualquer página pode mandar um formulário para qualquer site.
style="display:none"
Invisível: a Ana só vê "Parabéns!".
onload="document.forms[0].submit()"
Um pouco de JavaScript que envia o formulário assim que a página carrega, sem clique nenhum.

Como este guia não abre navegador, a Ana reproduziu o que o navegador faria com o curl. O -c cookies.txt guarda o cookie recebido no login (como o navegador guarda) e o -b cookies.txt o envia de volta (como o navegador envia sozinho). O -H 'Origin: …' imita o cabeçalho que diz de qual site veio o formulário:

$ curl -i -c cookies.txt localhost:8000/login.php
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:02:39 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Set-Cookie: PHPSESSID=6ceb8c39b417bf0769562ed04e51654a; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-type: text/html; charset=UTF-8

Logada como ana
$ curl -b cookies.txt -d 'email=golpista@exemplo.com' -H 'Origin: http://site-do-golpista.example' localhost:8000/alterar-email.php
E-mail alterado para golpista@exemplo.com

Pronto: com o cookie da Ana, o pedido vindo "do site do golpista" foi aceito. Sem o cookie (alguém que não está logado), não funciona:

$ curl -d 'email=golpista@exemplo.com' localhost:8000/alterar-email.php
Faça login primeiro

É isso que torna o CSRF perigoso: ele ataca justamente quem está logado. Qualquer ação que um usuário logado pode fazer com um formulário (trocar e-mail ou senha, fazer um pedido, apagar uma conta, publicar um post) pode ser disparada assim.


A defesa: um token que só a lojinha conhece

O site do golpista consegue enviar o formulário, mas não consegue ler as páginas da lojinha (o navegador proíbe um site de ler a resposta de outro). Então a lojinha coloca em cada formulário um número secreto e aleatório, o token CSRF, e só aceita o pedido se o número voltar certo. O golpista não tem como saber o número, então o formulário falso chega sem ele.

São três peças:

PeçaOndeCódigo
1. Criar o tokenNa sessão, uma vez$_SESSION['csrf'] = bin2hex(random_bytes(32));
2. Mandar o tokenCampo oculto em todo formulário que altera algo<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
3. Conferir o tokenNo começo de quem recebe o POSThash_equals($_SESSION['csrf'], $_POST['csrf'])

Peça 1: criar o token

<?php
require __DIR__ . '/inicio.php';

if (!isset($_SESSION['usuario'])) {
    http_response_code(401);
    exit("Faça login primeiro\n");
}

if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<p>E-mail atual: <?= htmlspecialchars($_SESSION['email']) ?></p>
<form method="post" action="alterar-email.php">
    <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
    <input type="email" name="email" required>
    <button>Alterar e-mail</button>
</form>
require __DIR__ . '/inicio.php'
Um arquivo que configura o cookie e chama session_start(). Você o verá na seção sobre SameSite.
if (empty($_SESSION['csrf']))
Cria o token só se ainda não existir: um token por sessão, o mesmo para todos os formulários.
random_bytes(32)
32 bytes aleatórios de verdade, próprios para segurança (diferente de rand() ou mt_rand(), que são previsíveis).
bin2hex(…)
Transforma os bytes (que podem ser caracteres invisíveis) em texto hexadecimal: 64 caracteres de 0-9 e a-f, seguros para colocar num HTML.

Peça 2: o campo oculto

<input type="hidden"> é um campo que não aparece na tela, mas é enviado com o formulário como qualquer outro. Veja o que a lojinha devolve para a Ana logada (o login.php agora também usa o inicio.php):

$ curl -i -c cookies.txt localhost:8000/login.php
HTTP/1.1 200 OK
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:02:46 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Set-Cookie: PHPSESSID=e9622eb6ec5b671dd698792ddbd6eb74; path=/; HttpOnly; SameSite=Lax
Content-type: text/html; charset=UTF-8

Logada como ana
$ curl -b cookies.txt localhost:8000/conta.php
<p>E-mail atual: ana@lojinha.com</p>
<form method="post" action="alterar-email.php">
    <input type="hidden" name="csrf" value="142c8d20e0ebe6148d7b666822c12df6804b2ff5240a1cc414eecb720f958b5b">
    <input type="email" name="email" required>
    <button>Alterar e-mail</button>
</form>

O token é aquele número de 64 caracteres. Ele é diferente para cada sessão: outra pessoa (ou a mesma Ana, em outro login) recebe outro. O value não precisa de htmlspecialchars porque o token só tem 0-9a-f, que nós mesmos geramos.

Peça 3: conferir com hash_equals

<?php
require __DIR__ . '/inicio.php';

if (!isset($_SESSION['usuario'])) {
    http_response_code(401);
    exit("Faça login primeiro\n");
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit("Use o formulário\n");
}

$tokenSessao = $_SESSION['csrf'] ?? '';
$tokenEnviado = $_POST['csrf'] ?? '';

if (!is_string($tokenEnviado) || $tokenSessao === '' || !hash_equals($tokenSessao, $tokenEnviado)) {
    http_response_code(403);
    exit("Pedido recusado: token CSRF inválido\n");
}

$_SESSION['email'] = $_POST['email'];
echo 'E-mail alterado para ', htmlspecialchars($_SESSION['email']), "\n";
LinhaO que faz
!== 'POST' → 405Só aceita POST. Ações que mudam dados nunca devem funcionar por GET (um simples <img src="…/alterar-email.php?email=…"> faria um GET).
$_SESSION['csrf'] ?? ''O token guardado. Se a sessão não tiver token (o usuário nunca abriu o formulário), vale ''.
$_POST['csrf'] ?? ''O token que veio no formulário. Formulário falso = não veio = ''.
!is_string($tokenEnviado)Recusa se alguém mandar um array (csrf[]=x), que faria o hash_equals dar erro fatal (veja os erros no fim da aula).
$tokenSessao === ''Sem token na sessão, não há o que comparar: recusa.
hash_equals($tokenSessao, $tokenEnviado)Compara os dois textos. Devolve true se forem idênticos.
http_response_code(403)403 Forbidden: "proibido".

Por que hash_equals e não ===?

O === compara os textos letra por letra e para na primeira diferença. Um token que começa errado é recusado um pouquinho mais rápido que um que só erra no fim. A diferença é de bilionésimos de segundo, mas medindo milhares de tentativas um atacante consegue descobrir o token letra por letra (isso se chama ataque de tempo, timing attack). O hash_equals ("hashes iguais?") foi feito para comparar segredos: ele sempre gasta o mesmo tempo, esteja o erro onde estiver. Regra: segredo se compara com hash_equals (tokens, assinaturas, códigos de confirmação). Para senhas, quem compara é o password_verify (aula Senhas). A ordem importa: primeiro o valor conhecido (o da sessão), depois o que veio do usuário.

O ataque de novo

$ curl -i -b cookies.txt -d 'email=golpista@exemplo.com' localhost:8000/alterar-email.php
HTTP/1.1 403 Forbidden
Host: localhost:8000
Date: Sat, 17 Oct 2026 00:02:46 GMT
Connection: close
X-Powered-By: PHP/8.5.8
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-type: text/html; charset=UTF-8

Pedido recusado: token CSRF inválido
$ curl -b cookies.txt -d 'csrf=123456&email=golpista@exemplo.com' localhost:8000/alterar-email.php
Pedido recusado: token CSRF inválido

Mesmo com o cookie da Ana, o pedido sem token (o formulário do golpista) e o pedido com um token chutado foram recusados. E a Ana de verdade, usando o formulário da lojinha? O navegador manda o campo oculto junto. Com o curl, a Ana pega o token da página (o grep -o separa a sequência de 64 caracteres hexadecimais) e o envia:

$ token=$(curl -s -b cookies.txt localhost:8000/conta.php | grep -o '[0-9a-f]\{64\}')
$ curl -b cookies.txt -d "csrf=$token&email=ana.souza@lojinha.com" localhost:8000/alterar-email.php
E-mail alterado para ana.souza@lojinha.com
$ curl -b cookies.txt localhost:8000/conta.php
<p>E-mail atual: ana.souza@lojinha.com</p>
<form method="post" action="alterar-email.php">
    <input type="hidden" name="csrf" value="142c8d20e0ebe6148d7b666822c12df6804b2ff5240a1cc414eecb720f958b5b">
    <input type="email" name="email" required>
    <button>Alterar e-mail</button>
</form>

Funcionou, e o token continua o mesmo durante a sessão.


Experimente: um formulário protegido

Abaixo está a página "Minha conta" inteira num arquivo só, com as três peças marcadas nos comentários. O editor roda em modo web, com sessão: o que você faz no formulário fica guardado entre um envio e outro.

Experimente: (1) Rode e abra a aba Código-fonte HTML para ver o campo oculto com o token. (2) Na aba Resultado, digite um e-mail novo e envie: deve aparecer "E-mail alterado!". (3) Agora imite o golpista: apague a linha do <input type="hidden" …>, rode de novo e envie o formulário. O pedido chega sem token e é recusado.

<?php
session_start();
$_SESSION['email'] ??= 'ana@lojinha.com';

// 1. Um token por sessão, criado na primeira visita
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$mensagem = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 3. Conferir o token antes de qualquer mudança
    $tokenEnviado = $_POST['csrf'] ?? '';
    if (!is_string($tokenEnviado) || !hash_equals($_SESSION['csrf'], $tokenEnviado)) {
        http_response_code(403);
        $mensagem = 'Pedido recusado: token CSRF inválido.';
    } else {
        $_SESSION['email'] = $_POST['email'] ?? $_SESSION['email'];
        $mensagem = 'E-mail alterado!';
    }
}
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Minha conta</title></head>
<body>
<h1>Minha conta</h1>
<p><strong><?= htmlspecialchars($mensagem) ?></strong></p>
<p>E-mail atual: <?= htmlspecialchars($_SESSION['email']) ?></p>
<form method="post">
    <!-- 2. O token vai escondido no formulário -->
    <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
    <input type="email" name="email" placeholder="novo e-mail" required>
    <button>Alterar e-mail</button>
</form>
</body>
</html>

No computador da Ana, o mesmo arquivo servido pelo php -S responde assim a um POST sem token (o trecho do <body>):

$ curl -s -d 'email=golpista@exemplo.com' localhost:8000/index.php
…
<h1>Minha conta</h1>
<p><strong>Pedido recusado: token CSRF inválido.</strong></p>
<p>E-mail atual: ana@lojinha.com</p>
…

SameSite: o navegador ajudando

Os cookies têm um atributo, SameSite ("mesmo site"), que diz ao navegador quando ele pode enviar o cookie em requisições que partiram de outro site. Compare o cabeçalho Set-Cookie das duas versões do login, lá em cima:

Set-Cookie: PHPSESSID=6ceb8c39b417bf0769562ed04e51654a; path=/                      « padrão do PHP
Set-Cookie: PHPSESSID=e9622eb6ec5b671dd698792ddbd6eb74; path=/; HttpOnly; SameSite=Lax « com o inicio.php

A diferença veio deste arquivo, incluído no começo de todas as páginas da versão protegida:

<?php
session_set_cookie_params([
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();
session_set_cookie_params([...])
Configura o cookie da sessão. Precisa vir antes do session_start().
'httponly' => true
O JavaScript da página não consegue ler o cookie (dificulta roubá-lo por XSS). Não tem a ver com CSRF, mas é bom hábito.
'samesite' => 'Lax'
O navegador não envia o cookie em POSTs vindos de outro site. O formulário escondido do golpista chegaria sem o cookie, ou seja, como alguém não logado.
'secure' => true
(Não usado aqui porque o php -S é http.) No site real, com HTTPS, acrescente: o cookie só viaja por conexão criptografada.
ValorO navegador envia o cookie em pedidos vindos de outro site?Uso
StrictNunca. Nem quando você clica num link para a lojinha vindo do e-mail (você chega "deslogado").Bancos, áreas administrativas.
LaxSó em navegação comum com GET (clicar num link). Em POST, imagens, iframes: não.O bom padrão para a maioria dos sites.
NoneSempre (exige também Secure).Só quando outro site precisa mesmo usar a sua sessão (ex.: um widget incorporado).

Para todo o site, sem mexer no código, o mesmo vale no php.ini: session.cookie_samesite = "Lax" e session.cookie_httponly = 1. Para cookies seus, o setcookie aceita a mesma opção: setcookie('tema', 'escuro', ['samesite' => 'Lax']) (veja Cookies).

SameSite não substitui o token. O curl não é um navegador e não respeita SameSite (por isso a demonstração acima precisou do token para ser barrada). Os navegadores respeitam, mas: navegadores antigos ignoram o atributo; Lax deixa passar GET (se alguma ação sua muda dados por GET, ela continua vulnerável); e "mesmo site" inclui subdomínios (um blog.lojinha.com invadido pode atacar loja.lojinha.com). Use os dois: SameSite=Lax como segunda camada, token como proteção principal.

Formas diferentes e boas práticas

SituaçãoO que fazer
Vários formulários no siteO mesmo token da sessão em todos. Crie uma função campoCsrf(): string que devolve o <input type="hidden"…> e outra conferirCsrf(): void que recusa com 403, e chame-as em todo lugar.
Depois do loginChame session_regenerate_id(true) (troca o id da sessão) e gere um token novo. Assim um token de antes do login não serve depois.
Requisições via JavaScript (fetch)Mande o token num cabeçalho, como X-CSRF-Token, e confira com $_SERVER['HTTP_X_CSRF_TOKEN'].
Ações muito sensíveis (trocar senha, apagar conta)Além do token, peça a senha atual de novo.
FrameworksLaravel e Symfony já geram e conferem o token (no Laravel, a diretiva @csrf no formulário). Por baixo, é exatamente o que você fez aqui.
Camada extraNavegadores modernos mandam o cabeçalho Sec-Fetch-Site (same-origin, cross-site…) e o Origin. Recusar POSTs com Sec-Fetch-Site: cross-site é uma boa defesa adicional.

Regras de ouro: (1) GET só consulta, nunca altera; (2) todo POST que altera algo confere o token; (3) cookies de sessão com HttpOnly, SameSite=Lax e, em produção, Secure.


Quando dá errado

"hash_equals(): Argument #2 ($user_string) must be of type string, array given"

Sem o is_string, um atacante pode mandar o campo como array (csrf[]=x):

$ curl -b cookies.txt -d 'csrf[]=x&email=golpista@exemplo.com' localhost:8000/sem-is-string.php
<br />
<b>Fatal error</b>:  Uncaught TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, array given in /home/ana/csrf2/sem-is-string.php:17
Stack trace:
#0 /home/ana/csrf2/sem-is-string.php(17): hash_equals(Object(SensitiveParameterValue), Object(SensitiveParameterValue))
#1 {main}
  thrown in <b>/home/ana/csrf2/sem-is-string.php</b> on line <b>17</b><br />

O que significa: no PHP, csrf[]=x no formulário vira $_POST['csrf'] = ['x']. O hash_equals só aceita textos e lançou um TypeError. O e-mail não foi trocado, mas a página quebrou mostrando o caminho do arquivo. Repare em Object(SensitiveParameterValue): o PHP esconde os argumentos do hash_equals no rastro do erro, para o token não vazar na mensagem.

Como consertar: confira o tipo antes, como no alterar-email.php protegido. Com o is_string, a resposta é a de sempre:

$ curl -b cookies.txt -d 'csrf[]=x&email=golpista@exemplo.com' localhost:8000/alterar-email.php
Pedido recusado: token CSRF inválido
O usuário de verdade recebe "token CSRF inválido"

O que significa: o formulário foi enviado com um token que não bate com o da sessão. Causas comuns: o formulário não tem o campo oculto (esqueceu o <input type="hidden">); o name do campo é diferente do que o PHP lê (token × csrf); a sessão expirou (a página ficou aberta horas) ou foi trocada por um novo login; ou a página de destino não chamou session_start(), e então $_SESSION está vazio.

Como consertar: confira o código-fonte da página (o campo está lá, com o nome certo?) e se todos os arquivos começam a sessão. Para sessão expirada, mostre uma mensagem amigável ("sua sessão expirou, recarregue a página") em vez de só "403".

"Use o formulário" (405) ao abrir o endereço no navegador
$ curl -b cookies.txt localhost:8000/alterar-email.php
Use o formulário

O que significa: abrir a URL na barra de endereços faz um GET, e a página só aceita POST. Isso é proposital.

Como consertar: nada a consertar. Se você precisa de uma página de "confirmação", faça-a mostrar um formulário (com token) que envia o POST.

"session_set_cookie_params(): Session cookie parameters cannot be changed when a session is active"
<?php
session_start();
session_set_cookie_params([
    'httponly' => true,
    'samesite' => 'Lax',
]);
$ curl -s localhost:8000/ordem.php
<br />
<b>Warning</b>:  session_set_cookie_params(): Session cookie parameters cannot be changed when a session is active (started from /home/ana/csrf2/ordem.php on line 2) in <b>/home/ana/csrf2/ordem.php</b> on line <b>3</b><br />

O que significa: "os parâmetros do cookie da sessão não podem ser mudados com uma sessão ativa (iniciada na linha 2)". O session_set_cookie_params foi chamado depois do session_start(), e o cookie ficou sem SameSite. Os parâmetros do cookie precisam ser definidos antes de a sessão começar.

Como consertar: centralize as duas chamadas num arquivo como o inicio.php, nesta ordem, e inclua-o no topo de todas as páginas.


Referência rápida

O quêComo
Gerar o token$_SESSION['csrf'] = bin2hex(random_bytes(32)); (se ainda não existir)
Campo oculto<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
Conferiris_string($t) && hash_equals($_SESSION['csrf'], $t); senão http_response_code(403)
Comparar segredoshash_equals($conhecido, $enviado) (tempo constante)
Cookie de sessãosession_set_cookie_params(['httponly' => true, 'samesite' => 'Lax', 'secure' => true]) antes do session_start()
No php.inisession.cookie_samesite = "Lax", session.cookie_httponly = 1, session.cookie_secure = 1
Depois do loginsession_regenerate_id(true); e token novo
Só POST alteraif ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); … }

Exercícios

1. Bytes aleatórios seguros

Complete a geração do token:

$_SESSION['csrf'] = bin2hex();

2. O campo que não aparece

Complete o tipo do campo que leva o token no formulário:

<input type="" name="csrf" value="…">

3. Comparar sem vazar tempo

Complete com a função que compara segredos em tempo constante:

if (!($_SESSION['csrf'], $tokenEnviado)) {

4. Por que o ataque CSRF funciona contra um site sem token?

5. Com o cookie de sessão em SameSite=Lax, o que acontece com o formulário POST escondido no site do golpista?

6. Qual destas ações está errada?

Missão final: a primeira requisição desta caixa já é o ataque: um POST com email=golpista@exemplo.com e sem token, como o do formulário escondido. Do jeito que está, a página aceita e mostra "E-mail alterado para golpista@exemplo.com". Proteja a página com as três peças: crie o token na sessão com random_bytes, coloque o campo oculto no formulário e confira com hash_equals antes de trocar o e-mail, mostrando "Pedido recusado: token CSRF inválido." quando falhar. Depois de rodar, use o formulário de verdade para conferir que a troca legítima continua funcionando.

<?php
session_start();
$_SESSION['email'] ??= 'ana@lojinha.com';

$mensagem = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $_SESSION['email'] = $_POST['email'] ?? $_SESSION['email'];
    $mensagem = 'E-mail alterado para ' . $_SESSION['email'];
}
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Minha conta</title></head>
<body>
<h1>Minha conta</h1>
<p><strong><?= htmlspecialchars($mensagem) ?></strong></p>
<p>E-mail atual: <?= htmlspecialchars($_SESSION['email']) ?></p>
<form method="post">
    <input type="email" name="email" placeholder="novo e-mail" required>
    <button>Alterar e-mail</button>
</form>
</body>
</html>
<?php
session_start();
$_SESSION['email'] ??= 'ana@lojinha.com';

if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$mensagem = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $tokenEnviado = $_POST['csrf'] ?? '';
    if (!is_string($tokenEnviado) || !hash_equals($_SESSION['csrf'], $tokenEnviado)) {
        http_response_code(403);
        $mensagem = 'Pedido recusado: token CSRF inválido.';
    } else {
        $_SESSION['email'] = $_POST['email'] ?? $_SESSION['email'];
        $mensagem = 'E-mail alterado para ' . $_SESSION['email'];
    }
}
?>
<!doctype html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Minha conta</title></head>
<body>
<h1>Minha conta</h1>
<p><strong><?= htmlspecialchars($mensagem) ?></strong></p>
<p>E-mail atual: <?= htmlspecialchars($_SESSION['email']) ?></p>
<form method="post">
    <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
    <input type="email" name="email" placeholder="novo e-mail" required>
    <button>Alterar e-mail</button>
</form>
</body>
</html>

Resumo

  • CSRF: um site do golpista faz o navegador de um usuário logado enviar um formulário para o seu site; o cookie de sessão vai junto automaticamente.
  • Defesa principal: um token secreto por sessão (bin2hex(random_bytes(32))), enviado num <input type="hidden"> e conferido com hash_equals antes de qualquer alteração. O golpista não consegue ler o token.
  • Confira também se o token é texto (is_string) e responda 403 quando falhar.
  • hash_equals compara segredos em tempo constante; === vaza informação pelo tempo.
  • SameSite=Lax (com HttpOnly e, em HTTPS, Secure) faz o navegador não mandar o cookie em POSTs de outros sites: ótima segunda camada, mas não substitui o token.
  • GET nunca altera dados.