PHPPreventing CSRF

Preventing CSRF (Cross-Site Request Forgery)

Cross-site request forgery abuses a fact that is easy to overlook: browsers attach a site's cookies to every request sent to that site, regardless of which page the request came from. If a victim is logged into your banking site in one tab, and in another tab they visit a page controlled by an attacker, that malicious page can contain a form or an image tag that submits a request straight to your banking site. The browser happily attaches the victim's session cookie to that request, because as far as the browser is concerned it is just a normal request to a site the victim is already logged into. The banking site has no way to tell that the request did not originate from its own form — the session looks completely valid.

What a forged request looks like

Imagine a money-transfer endpoint that accepts a POST request with an account number and an amount, and trusts the session cookie alone to identify who is making the request. An attacker hosts an unrelated page containing a hidden, auto-submitting form that targets that exact endpoint.

evil.example/free-prize.html

HTML
<body onload="document.forms[0].submit()">
  <form action="https://bank.example/transfer" method="POST" style="display:none">
    <input name="to_account" value="ATTACKER-ACCOUNT-123">
    <input name="amount" value="5000">
  </form>
</body>

If a logged-in victim simply visits that page, their browser submits the hidden form to the bank automatically, cookies and all, and the transfer goes through as if the victim had clicked the button themselves — because from the bank's point of view, a logged-in session made a well-formed request. The victim never entered a password, never saw a confirmation dialog, and may not even know the request happened.

The fix: a per-session CSRF token

The defense is to require every state-changing request to include a secret value that only your own page could have known — something an attacker's page, hosted on a different origin, has no way to read or guess. Generate a random token once per session, store it server side in $_SESSION, and embed it as a hidden field in every form that performs a sensitive action.

Generating and storing a token

PHP
<?php
session_start();

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

random_bytes() is important here — it uses a cryptographically secure source of randomness, unlike rand() or mt_rand(), which are predictable enough that an attacker could potentially guess or brute-force a token generated with them.

transfer-form.php — embedding the token

HTML
<form action="/transfer" method="POST">
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8'); ?>">
    <input type="text" name="to_account">
    <input type="text" name="amount">
    <button type="submit">Transfer</button>
</form>
Validating the token with hash_equals()

When the form is submitted, compare the token that came back with the one stored in the session, and reject the request if they do not match exactly. Use hash_equals() for this comparison rather than the == or === operators.

transfer.php — validating the token

PHP
<?php
session_start();

$submitted = $_POST['csrf_token'] ?? '';
$expected = $_SESSION['csrf_token'] ?? '';

if ($expected === '' || !hash_equals($expected, $submitted)) {
    http_response_code(403);
    exit('Invalid or missing CSRF token.');
}

// safe to process the transfer now
Never compare tokens with == or ===
A plain string comparison like $expected === $submitted exits as soon as it finds the first differing byte, so the time it takes to fail depends on how many leading characters matched. Repeated over many requests, that timing difference can theoretically let an attacker guess a secret token one byte at a time — a timing attack. hash_equals() always takes the same amount of time regardless of where the strings differ, which removes that side channel entirely. It is cheap to use correctly and there is no good reason to reach for a plain comparison instead.
SameSite cookies as a complementary defense

The SameSite cookie attribute tells the browser not to send a cookie along with requests that originate from another site, which directly undercuts the mechanism CSRF depends on. Setting SameSite=Lax (the current browser default when nothing is specified) still allows the cookie on simple top-level navigations like clicking a link, but blocks it on cross-site form POSTs and background requests — exactly the pattern the forged form above relies on. SameSite=Strict is even tighter, blocking the cookie on cross-site navigation entirely, at the cost of logging a user out if they arrive at your site by clicking a link from somewhere else.

Setting SameSite on the session cookie

PHP
<?php
session_set_cookie_params([
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

SameSite is a strong complementary defense, not a full replacement for CSRF tokens — older browsers may not honor it, and some legitimate cross-site flows (payment redirects, single sign-on callbacks) need cookies to travel cross-site, which can force a looser setting on parts of an application. Tokens remain the primary, framework-independent defense.

  • Generate a random per-session token with random_bytes(), never rand() or mt_rand().

  • Embed the token as a hidden field in every form that performs a state-changing action.

  • Validate the submitted token against the session token with hash_equals(), never == or ===.

  • Set SameSite=Lax or Strict on session cookies as a second, complementary layer.

  • Apply CSRF protection to every state-changing request — POST, PUT, DELETE — not just login forms.

GET requests should never change state
CSRF protection assumes that reading a resource (GET) is harmless and only writing or changing one (POST, PUT, DELETE) needs a token. That assumption breaks immediately if an endpoint performs a deletion or update in response to a GET request, since a plain `<img src="...">` tag on an attacker's page can trigger a GET with no form and no JavaScript at all. Keep state-changing logic behind POST or another non-GET method.
Tip
Most PHP frameworks (Laravel, Symfony) generate and verify CSRF tokens automatically for every form built with their helpers. If you are working in a framework, confirm the built-in protection is actually enabled for the routes you care about rather than reimplementing token handling by hand.