Cross-site request forgery (CSRF) tricks a logged-in user’s browser into sending a request to your site that the user never meant to send.
Browsers attach cookies automatically. So if you are logged in to shop.example, and you visit a harmful page that contains a hidden form pointing to shop.example/delete-account, your browser sends the request with your session cookie. The server sees a valid, logged-in request.
<!-- on the attacker's page -->
<form action="https://shop.example/transfer.php" method="post" id="f">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="500">
</form>
<script>document.getElementById("f").submit();</script>
The defence: a CSRF token
A token is a random secret that only your page knows. You put it in every form and check it on submit. The other site cannot read it (the browser’s same-origin rules stop that), so it cannot include a valid token.
<?php
function csrfToken(array &$session): string
{
$session["csrf_token"] ??= bin2hex(random_bytes(32));
return $session["csrf_token"];
}
function csrfValid(array $session, ?string $submitted): bool
{
return isset($session["csrf_token"], $submitted)
&& hash_equals($session["csrf_token"], $submitted);
}
$session = [];
$token = csrfToken($session); // put this in the form
echo strlen($token) . " characters\n";
var_dump(csrfValid($session, $token)); // the real form
var_dump(csrfValid($session, "forged-value")); // another site
var_dump(csrfValid($session, null)); // no token at all
64 characters
bool(true)
bool(false)
bool(false)
hash_equals() compares in constant time, which avoids timing attacks.
In the form and the handler
<form method="post" action="transfer.php">
<input type="hidden" name="csrf_token" value="<?= h(csrfToken($_SESSION)) ?>">
<!-- other fields -->
<button type="submit">Send</button>
</form>
<?php
session_start();
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
http_response_code(405);
exit;
}
if (!csrfValid($_SESSION, $_POST["csrf_token"] ?? null)) {
http_response_code(403);
exit("Invalid form token.");
}
// safe to continue
Rules
- Use POST (or PUT, DELETE) for anything that changes data, never GET.
- Check the token on every state-changing request: login, logout, delete, change password, settings.
- One token per session is fine. Tokens per form are stronger.
- Do not put the token in the URL.
SameSite cookies
The SameSite cookie attribute tells the browser not to send the cookie on many cross-site requests. With SameSite=Lax (the default in modern browsers) or Strict, the forged form above would arrive without the session cookie, so the user would not be logged in.
<?php
session_set_cookie_params(["samesite" => "Lax", "httponly" => true, "secure" => true]);
session_start();
Use both: SameSite for a strong default, and tokens as the real guarantee. SameSite alone has gaps (older browsers, Lax allows top-level GET navigation, subdomains).
Also check Origin
For extra safety on important actions, compare the Origin (or Referer) header with your own domain.
<?php
function originIsOurs(?string $origin, string $host): bool
{
return $origin !== null && parse_url($origin, PHP_URL_HOST) === $host;
}
var_dump(originIsOurs("https://shop.example", "shop.example"));
var_dump(originIsOurs("https://evil.example", "shop.example"));
var_dump(originIsOurs(null, "shop.example"));
bool(true)
bool(false)
bool(false)
APIs
Browser-based APIs that use cookies need CSRF protection. APIs that use a token in an Authorization header (not a cookie) are not affected by CSRF in the same way, because the browser does not add that header by itself.
What does not stop CSRF
- Checking that the request is POST (the attacker can send POST).
- A secret cookie value (the browser attaches it automatically).
- Keeping the URL secret.
Why this matters in WordPress and Laravel
wp_nonce_field(), wp_verify_nonce()). Laravel adds @csrf to forms and checks it for you. Both do what you just built.