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?
answer
- custom header maps to $_SERVER key
- HTTP_ prefix, uppercased, hyphens to underscores
- X-CSRF-Token -> $_SERVER['HTTP_X_CSRF_TOKEN']
- compare with hash_equals against $_SESSION
- handy for JSON bodies with no form field
basics
~20 sPHP 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.
solid answer
~40 sPHP populates `$_SERVER` with each request header under a mangled key: **`HTTP_` + the header name uppercased, with hyphens turned into underscores**. So `X-CSRF-Token: abc...` is read as **`$_SERVER['HTTP_X_CSRF_TOKEN']`**. For a `fetch`/XHR call that sends a JSON body (and so has no `$_POST` field), the token rides in that header and the server checks it the same way: `hash_equals($_SESSION['csrf'] ?? '', $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '')`. A header is convenient because same-origin script can set it, while a cross-origin page is blocked by CORS from adding a custom header to a credentialed request. Whether you accept the token in a hidden field, a header, or both is your app's choice; PHP just needs you to read `$_POST` or the `HTTP_*` key and compare with `hash_equals`. The carriage trade-offs are owned by the CSRF protocol topics.
go deeper
Know that request headers appear in $_SERVER and that X-CSRF-Token maps to HTTP_X_CSRF_TOKEN.
Explain the HTTP_ prefix/uppercase/underscore transform and why a header suits JSON fetch calls with no form field.
Wire a handler that accepts the token from a field or header, guards missing values, and compares with hash_equals.
Decide the API's token-carriage policy and reason about its interaction with CORS preflight and other CSRF defences.
## How PHP surfaces a request header There is no `$_HEADERS` superglobal in core PHP. Instead, PHP copies request headers into `$_SERVER` with a fixed transformation: - prefix with **`HTTP_`**, - **uppercase** the header name, - replace **hyphens with underscores**. So: | Request header | `$_SERVER` key | |---|---| | `X-CSRF-Token` | `$_SERVER['HTTP_X_CSRF_TOKEN']` | | `Accept-Language` | `$_SERVER['HTTP_ACCEPT_LANGUAGE']` | | `Content-Type` | `$_SERVER['CONTENT_TYPE']` (a documented exception — no `HTTP_`) | (`Content-Type` and `Content-Length` are the two that appear without the `HTTP_` prefix.) The custom `X-CSRF-Token` follows the normal rule, so you read it as `$_SERVER['HTTP_X_CSRF_TOKEN']`. ## Reading and checking it ```php $sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['csrf'] ?? '', $sent)) { http_response_code(403); exit; } ``` The `?? ''` matters: if the header is absent the key does not exist, and passing the missing value straight to `hash_equals` would throw a `TypeError`. The comparison is the same constant-time `hash_equals` used for the hidden-field case; only the *source* of the submitted token changes. ## Why a header, and when A hidden form field works for classic `<form method="post">` submissions. But a `fetch`/`XMLHttpRequest` call that posts **JSON** has no form fields at all, so: - the client reads the token — commonly from a `<meta name="csrf-token" content="...">` your PHP rendered, or from a cookie/response your app set — and - sets it as a request header: ```js fetch('/account/email', { method: 'POST', headers: {'X-CSRF-Token': token, 'Content-Type': 'application/json'}, body: JSON.stringify({email}) }); ``` A custom request header has a useful property: a cross-origin page cannot attach one to a credentialed request without triggering a CORS preflight your server must approve, so a forged cross-site `fetch` cannot silently carry `X-CSRF-Token`. That is one reason APIs favour the header. The *depth* of that argument, and how it compares with SameSite and Origin checks, is CSRF-protocol territory; the PHP fact is only how to read the header. ## Accepting either carriage A pragmatic handler accepts the token from whichever place the client used: ```php $sent = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['csrf'] ?? '', $sent)) { /* reject */ } ``` This lets normal forms use the hidden field and JSON clients use the header, with one comparison. Keep the token out of the URL either way — a query-string token leaks through `Referer` and logs. ## Pitfalls - Forgetting that the `$_SERVER` key is `HTTP_X_CSRF_TOKEN`, not `X-CSRF-Token` or `X_CSRF_TOKEN`. - Omitting `?? ''`, so a missing header becomes a `TypeError`. - Reading `getallheaders()` and assuming it exists everywhere — it is available on more SAPIs than it used to be, but the portable, always-present source is `$_SERVER`.
- What exactly is the `$_SERVER` key for an `X-CSRF-Token` header?`$_SERVER['HTTP_X_CSRF_TOKEN']`. PHP prefixes request headers with `HTTP_`, uppercases them, and replaces hyphens with underscores, so `X-CSRF-Token` becomes `HTTP_X_CSRF_TOKEN`. Guard the read with `?? ''` because the key is absent when the client did not send the header.
- Why not just read the header with `getallheaders()`?`getallheaders()` works on more SAPIs than it once did and is convenient, but the always-present, portable source is `$_SERVER`. For a single header, `$_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''` is simplest and avoids assuming the function exists in your runtime.
- Does sending the token in a header remove the need for the session copy?No. The header is just an alternative *carriage* for the submitted token; you still compare it against the server-side `$_SESSION` copy with `hash_equals`. Without an independent server-side value there is nothing trustworthy to check against, regardless of whether the client used a field or a header.
saying these in an interview costs you the question
- read the header as $_SERVER['X-CSRF-Token']
- PHP provides a built-in $_HEADERS superglobal
- a header token needs no server-side session copy
- compare the header value with == instead of hash_equals
- the $_SERVER key keeps the hyphens in the header name