skip to content

CSRF Tokens

A PHP app defends forms by storing a random token in $_SESSION, echoing it in a hidden field and comparing it on POST. Interviewers check the constant-time compare and when the token rotates.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In a plain PHP app, how do you protect a bank's change-email form with a CSRF token, from generating it to checking it on POST?

level: juniorimportance: must knowfreq 50%

answer

  1. random token stored server-side in $_SESSION
  2. echo it in a hidden form field
  3. compare submitted vs stored on POST
  4. use hash_equals, not ==
  5. reject when missing or mismatched

basics

~20 s

Generate an unpredictable token (bin2hex(random_bytes(32))), store it in $_SESSION, and echo it in a hidden field of the change-email form. On POST, compare the submitted field against the session copy with hash_equals(); if it is missing or does not match, reject the request.

solid answer

~40 s

The synchronizer-token flow has four PHP steps. **Generate** an unpredictable value — `bin2hex(random_bytes(32))` — once per session. **Store** it server-side in `$_SESSION['csrf']`. **Emit** it in the change-email form as a hidden field. **Verify** on the POST handler *before* changing anything: `if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) { http_response_code(400); exit; }`. It works because the token lives in the server-side session and is echoed only into pages your app renders; a hostile page can send the user's session cookie but **cannot read or guess the token**, so its forged POST lacks the field and is rejected. Compare with **`hash_equals`**, not `==`/`===`. The *reason* forged cross-site requests arrive authenticated, and synchronizer-token theory, are owned by the CSRF protocol topics.

go deeper

for a junior

Recall the four steps: generate, store in $_SESSION, echo in a hidden field, and compare on POST with hash_equals.

for a middle

Explain why the server-side session copy is the trustworthy one and why the attacker can replay the cookie but not the token.

for a senior

Design where the check sits (before side effects), handle missing fields, and choose hidden field vs header per client.

for a principal

Set the app-wide token policy and reason about its interaction with SameSite, Origin checks, and re-authentication on sensitive actions.

## The four steps in PHP A CSRF (cross-site request forgery) token is a secret the server plants in its own pages and requires back on any state-changing request. For a change-email form on a bank portal: 1. **Generate** — a per-session unpredictable value from the CSPRNG: ```php if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); // 64 hex chars } ``` 2. **Store** — it is already stored: `$_SESSION` is server-side, so the browser never sees the raw session contents, only the session *cookie*. 3. **Emit** — render it into the form the user legitimately loads: ```php echo '<input type="hidden" name="csrf" value="' . htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) . '">'; ``` 4. **Verify** — on the POST that changes the email, before any side effect: ```php if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) { http_response_code(400); exit; } ``` ## Why this stops the forgery A change-email endpoint that trusts only the session cookie is forgeable: a hostile page can make the victim's browser POST to it, and the browser attaches the bank's session cookie automatically. The token breaks that because: - it is **stored in the server-side session** and only **echoed into pages the bank itself renders**; - the same-origin policy stops the attacker's page from **reading** the bank's pages, so it cannot learn the token; - the forged POST therefore arrives **without the matching `csrf` field**, and step 4 rejects it. The attacker can replay the cookie but not the token — that asymmetry is the whole point. ## The pieces that are easy to get wrong | Mistake | Consequence | |---|---| | Comparing with `==` or `===` | timing side channel, and `==` can be bypassed by type juggling | | Checking the token *after* updating the email | the side effect already happened | | Storing the token in a hidden field only (no session copy) | nothing server-side to compare against — the attacker supplies both halves | | Putting the token in a GET query string | it leaks via `Referer`, logs, and history | | Forgetting `?? ''` on a missing field | a warning or a type error instead of a clean rejection | ## What belongs to PHP here, and what does not The PHP-specific content is: store in `$_SESSION`, emit in a hidden field, read from `$_POST`, and compare with `hash_equals`. The *concepts* — why the browser sends the cookie, what a synchronizer token proves, how SameSite and Origin checks compare — are owned by the CSRF protocol topics; the CSPRNG behind `random_bytes` is owned by the password/random leaf. An interview answer here is judged on wiring the four steps correctly and using `hash_equals`, not on re-deriving the attack.

  • Where must the token be stored for the check to mean anything?
    Server-side, in `$_SESSION`. The value echoed in the hidden field is only the copy the browser sends back; the trustworthy copy is the session one, which the attacker cannot read. If you keep the token *only* in the form and not in the session, there is nothing independent to compare against and the check is meaningless.
  • Should the token go in the URL query string instead of a hidden POST field?
    No. A token in a GET query string leaks through the `Referer` header, browser history, server logs, and shared links. Keep it in a hidden field of a POST form (or a request header for AJAX), and reserve GET for safe, non-state-changing requests that need no token.
  • Do you need a new token for every single request?
    Not necessarily. A single per-session token in `$_SESSION`, reused across the session, is a valid and common design and works cleanly with multiple tabs and the back button. Per-request rotation is stronger against some attacks but has trade-offs; that design question is owned by the CSRF synchronizer-token topic.

It is like a callback password a bank leaves on your account: they say it to you only on a line you initiated, and require you to repeat it before changing anything. A stranger phoning in your name has your account number (the cookie) but not the password (the token), so the change is refused.

saying these in an interview costs you the question

  • storing the token only in a hidden field is enough
  • comparing the token with == or === is fine
  • check the token after performing the state change
  • put the CSRF token in the URL query string
  • a GET request that changes data needs no token
  • the attacker's page can read the token from the bank's page
open as a page

In PHP, why compare a CSRF token with hash_equals() instead of ===, and which argument goes first?

level: middleimportance: must knowfreq 45%

basics

~20 s

=== returns as soon as it finds a differing byte, so its run time leaks how much of the secret matched — a timing side channel. hash_equals($known, $user) compares in constant time for equal-length strings. Pass the stored token first and the user-supplied value second.

open as a page

In PHP, how do you read a CSRF token that a fetch/AJAX call sends in an X-CSRF-Token header, and why is a header used?

level: middleimportance: should knowfreq 33%

basics

~20 s

PHP exposes a request header as $SERVER['HTTP' + uppercased name with hyphens as underscores], so X-CSRF-Token becomes $_SERVER['HTTP_X_CSRF_TOKEN']. Read it, compare with hash_equals against the $_SESSION copy. A header suits fetch/JSON calls that carry no form field.

open as a page

In PHP, would you keep one CSRF token per session in $_SESSION or one per form, and why regenerate the token after login?

level: seniorimportance: should knowfreq 32%

basics

~20 s

A single $_SESSION['csrf'] reused for the session is simplest and multi-tab friendly; per-form tokens are a $_SESSION map keyed by form and unset after use, but break the back button. Regenerate the token value at login, since one issued to a pre-auth session may be attacker-known.

open as a page