How CSRF Tokens Actually Work (And Why SameSite Is Not Enough)

CSRF exploits one browser behavior: cookies are attached automatically, no matter who initiated the request. Tokens work because an attacker can make your browser send a request but cannot read your page to find the token. Here is the whole mechanism, with working PHP.

Cross-site request forgery is one of the few vulnerability classes that is genuinely elegant, in the way a good magic trick is elegant. It does not steal your session, break your encryption, or inject anything. It simply asks the victim's browser to make a request — and the browser, being helpful, attaches the session cookie exactly as it would for any other request to your site. The server sees a perfectly authenticated request that the user never intended to make.

The attack

The victim is logged into your app. They visit an unrelated page controlled by someone else:

<!-- on attacker.example -->
<form action="https://yourapp.example/account/email"
      method="POST" id="f">
  <input type="hidden" name="email" value="attacker@evil.example">
</form>
<script>document.getElementById('f').submit();</script>

No user interaction. The form submits on load, the browser attaches your session cookie because the request goes to your domain, and your server changes the account email. From the server's point of view this is indistinguishable from the real user filling in the form.

The GET version is worse, because it does not even need a form:

<!-- if a state change is reachable by GET -->
<img src="https://yourapp.example/account/delete?confirm=1">

Which is the first rule and the easiest one to follow: never change state on a GET request. An <img> tag in a forum comment should not be able to delete an account, and the only reason it ever can is a route that accepts GET for something that is not a read.

Why a token fixes it

The asymmetry the defense rests on: an attacker can cause your browser to send a request, but cannot read the response to any request their page makes to your origin. The same-origin policy stops that. So if a valid request requires a value that only appears inside your own pages, the attacker cannot construct one.

1. Server generates a random token, stores it in
   the session, and renders it into the form.

2. Browser submits the form. Token comes back
   alongside the session cookie.

3. Server compares submitted token to the stored
   one. Match -> proceed. Mismatch -> reject.

An attacker's page can submit to your form URL,
but has no way to read your token. Step 3 fails.

This is the synchronizer token pattern, and it is the one to use.

The implementation

<?php
declare(strict_types=1);

final class Csrf
{
    private const KEY = '_csrf';

    /** The token for this session, created on first use. */
    public static function token(): string
    {
        if (empty($_SESSION[self::KEY])) {
            // 32 bytes of CSPRNG output. Not uniqid(),
            // not md5(time()) - those are guessable.
            $_SESSION[self::KEY] = bin2hex(random_bytes(32));
        }

        return $_SESSION[self::KEY];
    }

    public static function check(?string $submitted): bool
    {
        $expected = $_SESSION[self::KEY] ?? '';

        if ($expected === '' || $submitted === null || $submitted === '') {
            return false;
        }

        // Constant-time comparison. A plain === leaks
        // how many leading characters matched, through
        // timing, which is enough to guess a token
        // byte by byte given enough attempts.
        return hash_equals($expected, $submitted);
    }
}

Two details are doing the security work. random_bytes() is a cryptographically secure source — rand(), uniqid(), and anything derived from the current time are predictable, and a predictable token is not a token. And hash_equals() compares in constant time; === returns as soon as it finds a differing byte, which is a measurable timing signal an attacker can walk.

In the form:

<form method="post" action="/account/email">
  <input type="hidden" name="_csrf"
         value="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES) ?>">
  <input type="email" name="email" required>
  <button type="submit">Save</button>
</form>

In the handler, before anything else happens:

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!Csrf::check($_POST['_csrf'] ?? null)) {
        http_response_code(419);
        exit('Session expired. Please reload and try again.');
    }
    // ... only now touch any state
}

Check before validation, before database access, before anything with a side effect. A CSRF check that runs after the update is decorative.

Make it impossible to forget

The real failure mode is not a broken token implementation. It is the one form somebody added last month without the hidden field. So enforce it centrally, in your router or middleware, rather than per handler:

// runs for every request, before dispatch
$unsafe = ['POST', 'PUT', 'PATCH', 'DELETE'];

if (in_array($_SERVER['REQUEST_METHOD'], $unsafe, true)
    && !Csrf::check($_POST['_csrf'] ?? null)) {
    http_response_code(419);
    exit('Invalid CSRF token.');
}

Now a new form without a token fails loudly in development instead of shipping as a quiet hole. Default-deny beats remembering.

AJAX requests

Same token, sent as a header. Put it in a meta tag so scripts can read it:

<meta name="csrf-token"
      content="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES) ?>">
const token = document.querySelector('meta[name="csrf-token"]').content;

await fetch('/account/email', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token,
  },
  body: JSON.stringify({ email }),
});

Then accept either source server-side:

$submitted = $_POST['_csrf']
    ?? $_SERVER['HTTP_X_CSRF_TOKEN']
    ?? null;

Why SameSite is not the whole answer

SameSite=Lax — now the default in modern browsers — blocks the cookie on cross-site POST, which kills the classic attack outright. That is a large, genuine improvement, and it is why CSRF is less catastrophic than it was a decade ago.

It is not sufficient on its own:

  • Cross-site GET navigations still carry the cookie. Any state change on GET remains exploitable.
  • Same-site is not same-origin. A compromised subdomain is the same site. SameSite permits it; a token does not.
  • Browser variance. Your security should not depend on how old a visitor's browser is, or on a non-browser client's cookie policy.
  • Defense in depth. One misconfigured cookie — a SameSite=None added for an embed — should not remove your only protection.

Getting the details right

CSRF implementation details that matter
QuestionAnswer
Per session or per form?Per session is fine and much simpler. Per form adds little against CSRF and breaks the back button and multiple tabs.
Rotate the token?On login and privilege change, along with session_regenerate_id().
Protect GET?No — make GET have no side effects instead.
Protect login forms?Yes. Login CSRF is real: an attacker logs the victim into an account the attacker controls.
Token in a URL?Never. URLs land in logs, referrers, and browser history.
Also check Origin?Reasonable extra layer. Not a replacement — the header is absent on some legitimate requests.

Testing it

# with a valid session cookie but no token: must be rejected
curl -si -X POST https://example.com/account/email \
  -b 'session=REAL_SESSION_VALUE' \
  -d 'email=test@example.com' | head -n 1
# want: 4xx

# with a wrong token: must also be rejected
curl -si -X POST https://example.com/account/email \
  -b 'session=REAL_SESSION_VALUE' \
  -d 'email=test@example.com&_csrf=wrong' | head -n 1
# want: 4xx

Run both against every state-changing endpoint you have, not just the one you were working on. The endpoint somebody added in a hurry is the one that will be missing the check.

Published · Web Development Web Security