A Transfer Fires From Someone Else's Tab: Why a Session Cookie Says 'Authenticated', Not 'Intended'
The victim is logged into your site in one tab. In another tab they open a page they do not control:
<form id="f" action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="2500">
</form>
<script>document.getElementById('f').submit()</script>
The form posts, the browser dutifully attaches the session cookie, and your controller reads a perfectly authenticated request: valid session, valid user, valid CSRF-free handler. The money moves. Cross-site request forgery is a request that is authentic but not intentional — the server can prove who is asking, and nothing at all about what they meant to ask for. A one-pixel image does the same job against any state change reachable by GET: <img src="https://bank.example/transfer?to=attacker&amount=2500"> needs no form and no JavaScript, which is the first reason GET requests must never change state.
Notice what the attacker's page cannot do. Same-origin policy blocks it from reading responses from your domain, so it cannot fetch your form HTML and copy out a secret that lives there. That asymmetry is the entire defense: the server embeds a random secret in every form it renders, and only a page served from your origin could have read it. When the secret comes back, the request provably passed through your own form.
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32)); // 256 bits, one per session
}
?>
<form method="POST" action="/transfer">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<input name="to" value="attacker">
<input name="amount" value="2500">
<button type="submit">Send</button>
</form>
Three properties make that token work:
- It is unpredictable.
random_bytes()is a cryptographically secure generator;mt_rand(),uniqid(), andmd5(microtime())are guessable or low-entropy, and a token the attacker can predict is decoration. - It is bound to the session, not to the page. Stored in
$_SESSION, it survives navigation and page regeneration but dies with the session — an attacker who forces a new session gets their token, never the victim's. - It never travels where it can leak. Query strings end up in access logs,
Refererheaders, proxies, and browser history; a token in a URL is a token in five places you do not control.
CSRF is also easy to confuse with its cousins, and interviews routinely ask you to separate them:
| Attack | What it forges | Its own defense |
|---|---|---|
| CSRF | the request — the browser attaches real cookies to an action nobody chose | this topic: the per-session token |
| XSS | code running inside your origin — the attacker then acts as the user with full access | Escaping Output to Prevent XSS |
| Session fixation | the session id — the victim is forced to authenticate inside the attacker's session | session_regenerate_id(true) after login |
| Clickjacking | the click — the victim aims at your UI but hits an invisible overlay | frame-ancestors 'none' / X-Frame-Options |
A token only closes the first row, which is why "we have CSRF covered" says nothing about the other three. Note also which requests the browser fires on its own — a clicked link, an <img> src, a CSS url() — no form and no JavaScript required. That is why GET must never change state: it is a security control, and every POST, PUT, PATCH, and DELETE handler needs the same gate in front of it.
That covers classic HTML forms. Modern apps send state changes as fetch()/XHR from your own pages, and a hidden input does not apply — the token rides in a header instead:
<meta name="csrf-token" content="<?= e($_SESSION['csrf']) ?>">
fetch('/api/transfer', {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Content-Type': 'application/json' },
body: JSON.stringify({ to, amount })
});
The token can live in the page because the page is same-origin; the attacker's script cannot read it cross-origin, and cannot set the header on a form either — forms have no custom headers. The next chapter covers verification, why === is the wrong comparison, and where in your code the check belongs so no route forgets it.
hash_equals, Fail-Closed 403s, and Tokens That Never Leak
Rendering a token proves nothing until every state-changing route refuses requests that lack it. One gate, in front of your handlers, closes the whole class:
function csrf_verify(): void
{
$sent = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$known = $_SESSION['csrf'] ?? '';
if (!is_string($sent) || $known === '' || !hash_equals($known, $sent)) {
http_response_code(403);
exit('Invalid or missing CSRF token');
}
}
// called once in the front controller, before any POST/PUT/PATCH/DELETE handler runs
Four decisions inside that function are the interview material:
Why hash_equals() and not == or ===? hash_equals() compares two strings without returning early on the first differing byte, so response time carries no information about how many leading characters matched — the property you want when the value is a secret. === short-circuits, and worse, == invites PHP's type juggling: two token-looking strings such as 0e1234… and 0e5678… are both numeric strings evaluating to zero, so == reports them equal. hash_equals() also refuses to guess — it is the comparison PHP ships for exactly this job.
Why is_string($sent)? An attacker — or a confused client — can send csrf[]=x, turning the input into an array. hash_equals() expects strings and throws a TypeError in PHP 8, turning your 403 into a 500 with a stack trace. Reject the wrong type explicitly; a token field only ever accepts a string.
Why fail with 403 and no processing? CSRF checks must fail closed. A handler that logs a warning and continues executes the state change anyway, which means the control exists in the codebase and never in production. Reject before any database write, and log the referrer plus missing-token count — a spike is an active attack campaign.
Where does the check live? In the front controller (or a middleware/filter) for every non-idempotent method, once — not hand-written per route. Route-level checks rot: the new endpoint ships without one, and the only test that would catch it is an attacker's form. The same gate applies to JSON APIs, with the token arriving as X-CSRF-TOKEN from the <meta> tag shown in the previous chapter.
Token lifecycle details that come up in review:
// after login — new session id, session data (including the token) carried over
session_regenerate_id(true);
// rotate the token itself when the privilege level changes
$_SESSION['csrf'] = bin2hex(random_bytes(32));
Per-session tokens are the common trade: cheap, stateless to check, valid until logout. One-time tokens (store the issued value, delete it on use) additionally kill replay after a token leaks, at the cost of back-button failures and session storage of used values — which is why frameworks default to per-session tokens with rotation at login. What both designs forbid is the leak paths: tokens in URLs (logs, Referer), tokens echoed into page caches — a full-page cache that serves one user's form to another hands a stranger a working token — and tokens logged wholesale alongside $_POST. The same shape recurs in Defeating SQL Injection: injection breaks in, forgery rides in — and both die the moment a value stops being treated as syntax.
SameSite=Lax Is Not a Replacement for Tokens: Layering Cookie Policy and Origin Checks
After a decade of token-only defenses, browsers shipped SameSite, and a lot of teams concluded CSRF was solved. It is reduced, not solved — which is why token verification stays and cookie policy becomes the second layer.
SameSite value |
Cookie sent when | Trade-off |
|---|---|---|
Strict |
only same-site navigations | strongest; links from mail or chat open the site logged out |
Lax |
same-site plus top-level GET navigations (the browser default now) | safe only if GET never changes state |
None |
every context, including cross-site iframes | requires Secure; opt-in for genuine third-party embeds only |
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // never send it over plain http
'httponly' => true, // not readable from document.cookie
'samesite' => 'Lax',
]);
session_start();
Why Lax alone does not retire the token: legacy browsers and some embedded webviews never implemented it; an attacker-controlled subdomain (a stale DNS record, a user-content host) can set a cookie scoped to the parent domain — cookie tossing — which confuses which token the server sees; any accidental state change on a GET route is still reachable, because Lax sends cookies on top-level GET links; and bugs in one dependency can reintroduce cross-site submission. The token proves intent; SameSite only influences whether the cookie rides along.
Add an origin check as an independent second opinion:
$origin = $_SERVER['HTTP_ORIGIN'] ?? null; // sent on cross-origin POSTs
$expected = 'https://' . $_SERVER['HTTP_HOST'];
if ($origin !== null && !hash_equals($expected, $origin)) {
http_response_code(403);
exit('Cross-origin request rejected');
}
// modern browsers also send Sec-Fetch-Site: same-origin | same-site | cross-site | none
if (($_SERVER['SEC_FETCH_SITE'] ?? '') === 'cross-site') {
http_response_code(403);
exit('cross-site requests are not accepted');
}
Two caveats keep this a second layer rather than the primary one. Origin is absent on same-origin form posts and on some older browsers, so treat its presence as a positive signal (reject a mismatched value) rather than requiring it — and never require Referer, since legitimate Referrer-Policy: no-referrer clients send nothing. Sec-Fetch-Site is reliable but only from browsers that implement fetch metadata.
The misconception to kill in interviews: "our API is JSON, so CORS protects it." CORS governs whether a cross-origin page may read a response; it never stops the request from being sent. In fact application/x-www-form-urlencoded and multipart/form-data are "simple" content types that the browser does not preflight at all, so a cross-site HTML form posts straight through — the one thing that stops it is your token check (plus SameSite). Content-type checks (415 for non-JSON) add friction; they are not a CSRF control.
End with the property that makes all of this cohere: GET, HEAD, and OPTIONS must have no side effects. A password change, transfer, or delete reachable by GET cannot be defended reliably — top-level navigations carry cookies under Lax, images and links fire anywhere — so keep state changes on POST, require the token there, and verify it with hash_equals() before anything touches the database. The input reaching those handlers gets its own treatment in the next topic, Validating and Sanitizing Input.
Quiz
1. A state-changing POST arrives with a valid session cookie but no csrf field. What has the server actually proven?
2. Why compare the submitted token with hash_equals($_SESSION['csrf'], $sent) instead of $sent === $_SESSION['csrf']?
3. Your forms all carry valid tokens, but an auditor changes a POST route to accept GET with the same parameters and the check is skipped for GET. What is the consequence?
4. A teammate argues that because your API only accepts application/json, CSRF cannot apply. What is the accurate rebuttal?
Flashcards
Question
What does a CSRF token prove that a session cookie does not?
Click to reveal answer
Answer
Intent. The cookie proves which session is asking; the token is a random secret rendered into your own page and only readable same-origin, so a request carrying it passed through a page your site served. A cross-site attacker can make the browser send cookies but can never read your form to learn the secret.
Question
How is a CSRF token generated, stored, sent, and checked in PHP?
Click to reveal answer
Answer
Generate once with bin2hex(random_bytes(32)) into $_SESSION; render as a hidden input (forms) or meta tag consumed via the X-CSRF-TOKEN header (fetch); verify before any non-idempotent handler with hash_equals($_SESSION['csrf'], $sent), rejecting non-strings and missing values with a 403. Fail closed, never warn-and-continue.
Question
Why hash_equals instead of === or ==?
Click to reveal answer
Answer
hash_equals compares full strings with no early exit, so timing reveals nothing about a secret; == additionally falls for numeric-string juggling (0e… tokens both cast to 0 and compare equal). === short-circuits on the first byte. hash_equals also throws on arrays, so validate is_string() first.
Question
What do the three session cookie flags buy you, and what is still missing?
Click to reveal answer
Answer
HttpOnly keeps document.cookie from reading the session (limits XSS theft, not XSS action); Secure stops transmission over plain http; SameSite=Lax/Strict makes the browser withhold the cookie on cross-site posts (layer two against CSRF). Missing: the token — old webviews, subdomain cookie tossing, and any GET state change still bypass SameSite alone.
Question
Is CORS a CSRF defense? What about requiring application/json?
Click to reveal answer
Answer
No. CORS governs whether a cross-origin page may read a response, never whether the request is sent; urlencoded and multipart are simple types that skip preflight entirely. Requiring JSON forces a preflight and blocks naive forms, but it is a content-type property that disappears if the route also accepts urlencoded — the token gate is the control.
Revision Notes
Key Takeaways
- 1. A session cookie answers who is asking; only a per-session random token read from your own page answers what they intended
- 2. Generate with random_bytes(32), embed in forms and X-CSRF-TOKEN headers, verify once in the front controller with hash_equals and fail closed with 403
- 3. hash_equals avoids early-exit timing and 0e type juggling; is_string() first so csrf[]=x yields 403 instead of a TypeError 500
- 4. SameSite=Lax (cookie default) plus Secure and HttpOnly is layer two — tokens remain mandatory for old webviews, cookie tossing, and GET state changes
- 5. CORS, content type, and Referer checks are not CSRF controls; GET/HEAD/OPTIONS must have no side effects at all
Interview Tips
- • "Explain CSRF to me" — one sentence (authentic but not intentional), then the mechanism: cookies ride along cross-site, same-origin policy stops the attacker reading your form, so a per-session secret proves the form came from your page
- • "Why hash_equals?" — constant-time comparison with no early exit, plus the 0e numeric-string juggling trap of loose equality; mention is_string() because csrf[]=x would otherwise 500
- • "Do we still need tokens if we set SameSite=Strict?" — Strict is strong, but name the residual cases: non-browser clients and webviews, subdomain cookie tossing, cached pages, and any GET endpoint with side effects; defense in depth is the answer
- • "Where would you put the check in a real app?" — one middleware/front-controller gate covering POST/PUT/PATCH/DELETE before handlers run, never per-route hand checks; the failure mode is the route someone forgets
Cheat Sheet
CSRF Tokens for Forms
The attack: a cross-site page auto-submits a form (or fires <img src=…>) to your endpoint; the browser attaches the victim's session cookie, so the server sees an authenticated, well-formed, entirely unwanted request. Authentic ≠ intentional.
Issue the token:
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32)); // 256 bits, CSPRNG only — never mt_rand/uniqid
}
echo '<input type="hidden" name="csrf" value="' . e($_SESSION['csrf']) . '">';
// API pages: <meta name="csrf-token" content="…"> → fetch header X-CSRF-TOKEN
Verify once, in the front controller, before any non-idempotent handler:
$sent = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$known = $_SESSION['csrf'] ?? '';
if (!is_string($sent) || $known === '' || !hash_equals($known, $sent)) {
http_response_code(403); exit('Invalid or missing CSRF token'); // fail closed
}
Why hash_equals: no early exit (timing carries no info about matching prefixes) and no == type juggling — 0e123… == 0e456… both evaluate to 0. is_string() first, because csrf[]=x would otherwise throw TypeError (500) instead of 403.
Lifecycle: session_regenerate_id(true) after login (new id, data — including the token — carried over); rotate the token on privilege change; per-session tokens are the default, one-time tokens trade back-button failures for replay protection.
Never: token in a URL (logs, history, Referer), token echoed through a shared full-page cache (one user's form served to another), $_POST logged wholesale, or a "warn and continue" path — a CSRF check that does not reject is not a check.
SameSite is layer two, not a replacement:
| Value | Sent | Note |
|---|---|---|
Strict |
same-site only | mail/chat links open logged out |
Lax (default) |
same-site + top-level GET navigation | safe only if GET has no side effects |
None |
everything | requires Secure |
session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>true,'httponly'=>true,'samesite'=>'Lax']);
Still token: old webviews, subdomain cookie tossing, any state change reachable via GET.
Origin / fetch metadata (second opinion): reject a mismatched HTTP_ORIGIN with hash_equals() against https:// + HTTP_HOST; reject SEC_FETCH_SITE === 'cross-site'. Origin is absent on same-origin form posts → treat presence as signal, not requirement; never require Referer.
CORS ≠ CSRF protection: CORS controls reading responses, not sending them; urlencoded/multipart are "simple" types that skip preflight entirely.
Rule: GET/HEAD/OPTIONS must never change state — top-level navigations carry cookies under Lax, so a GET delete cannot be defended reliably.