CSRF: Synchronizer Tokens, SameSite Cookies, Double-Submit, and APIs

Implement synchronizer-token CSRF protection with hash_equals(), use SameSite cookies as a complementary defense, understand the double-submit-cookie pattern, and explain why CSRF protection differs for APIs authenticated with bearer tokens.

Cross-Site Request Forgery (CSRF) exploits a specific browser behavior: when a browser sends a request to a site, it automatically attaches whatever cookies that site has set, regardless of which PAGE initiated the request. That means if a victim is logged into your bank's website (cookie set) and then visits a completely unrelated malicious page that silently submits a form to https://bank.example/transfer, the browser dutifully attaches the victim's real session cookie to that forged request -- the bank's server sees what looks like a perfectly authenticated request and executes it. CSRF doesn't steal any data or break encryption; it abuses trust the SERVER places in a cookie that the BROWSER, not the user, chose to send. This lesson covers the standard defense (synchronizer tokens), a complementary browser-level defense (SameSite cookies), an alternative pattern (double-submit cookies), and why the whole threat model shifts for APIs that use bearer tokens instead of cookies.

Synchronizer Tokens: The Standard Defense

The synchronizer token pattern works by giving the server a way to confirm that a state-changing request genuinely originated from a page the server itself rendered, not from some other site. On each session, the server generates a random, unguessable token and stores it server-side (typically in the session). Every form that performs a state-changing action embeds that token as a hidden field. When the form is submitted, the server compares the submitted token against the one stored in the session -- if they match, the request is accepted as legitimate; if the token is missing or wrong, the request is rejected before any state-changing work happens. A malicious third-party page has no way to read the token out of the victim's session (that would require reading cross-origin content, which the browser's same-origin policy already prevents), so it cannot include a valid token in a forged request, even though it can still make the browser send the victim's cookies.

The comparison itself matters: tokens must be compared with hash_equals(), never == or ===. A naive string comparison can leak timing information -- how many leading characters matched before the comparison short-circuited -- that a sufficiently patient attacker could exploit to guess the token one character at a time (a timing side-channel attack). hash_equals() is specifically designed to run in constant time regardless of where the strings first differ, closing that side channel entirely. The token itself should be generated with a cryptographically secure source, random_bytes(), never something predictable like a counter or a hash of the user ID.

• Generate one random, unguessable token per session with random_bytes(), stored server-side in $_SESSION. • Embed the token as a hidden field in every state-changing form. • On submission, compare the submitted token to the session's token using hash_equals() -- never == or ===. • A forged cross-site request can't include a valid token because the attacker's page can't read the victim's session (same-origin policy). • hash_equals() runs in constant time, closing a timing side-channel that a naive comparison would leave open.

SameSite Cookies: A Complementary, Browser-Level Defense

The SameSite cookie attribute lets the server tell the browser directly: only attach this cookie to requests that originate from the same site, or restrict when it's sent on cross-site navigations. SameSite=Strict never sends the cookie on any cross-site request, even a user clicking a legitimate link from another site into yours (which can break some login flows). SameSite=Lax, the more commonly used default, sends the cookie on top-level cross-site GET navigations (so following a link from another site still works) but withholds it on cross-site POST/PUT/DELETE requests and on requests made by an embedded resource like an <img> or a background fetch() -- which covers the exact request shape CSRF attacks rely on. SameSite=None sends the cookie on every cross-site request and requires the Secure flag; it exists for legitimate cross-site embedding use cases and offers no CSRF protection on its own.

SameSite is a genuinely strong, largely 'free' mitigation -- setting SameSite=Lax (or Strict, where the login-flow tradeoff is acceptable) on the session cookie blocks the vast majority of practical CSRF attempts with a single configuration change and no code change to individual forms. But it should be treated as a COMPLEMENT to synchronizer tokens, not a replacement: SameSite is a browser-enforced behavior, and its protection depends entirely on the visiting browser correctly implementing and applying it -- older browsers, misconfigured proxies that strip the attribute, or edge cases in how a particular browser classifies 'same-site' can all leave a gap. Layering both (SameSite cookies at the browser level, synchronizer tokens at the application level) is defense in depth applied directly to this one threat.

• SameSite=Strict: cookie never sent cross-site, even on a legitimate link click from another site. • SameSite=Lax (common default): cookie sent on top-level cross-site GET navigation, withheld on cross-site POST/PUT/DELETE and embedded-resource requests -- covers the classic CSRF shape. • SameSite=None: cookie sent on every cross-site request (requires Secure) -- no CSRF protection by itself, used for legitimate cross-site embedding. • Treat SameSite as a complement to synchronizer tokens, not a replacement -- its protection depends on the visiting browser's implementation.

The Double-Submit Cookie Pattern

The double-submit-cookie pattern is an alternative to server-side session storage for the token, useful when the application is stateless or session storage is inconvenient (e.g. a horizontally scaled API without shared session state). The server sets the CSRF token as a cookie AND expects the client-side JavaScript to also read that cookie's value and submit it back as a separate request parameter or header on state-changing requests. The server then just checks that the two values -- the one arriving in the cookie and the one arriving in the request body/header -- match, with no server-side session lookup needed at all.

The security reasoning: an attacker's forged cross-site request will have the victim's real cookie attached automatically by the browser (that part of the attack still works), but the attacker's malicious page cannot READ that cookie's value (same-origin policy blocks cross-origin JavaScript from reading another site's cookies) to also include it as the matching request parameter. So the two values can never be made to match in a genuinely forged, cross-site request. This pattern is weaker than server-validated synchronizer tokens in one specific way -- it depends entirely on cookie confidentiality, so it offers zero protection if the application has ANY cookie-reading vulnerability elsewhere (like an XSS bug), whereas a session-stored synchronizer token is at least one layer further removed from a pure cookie-read.

• Server sets the CSRF token as a cookie; client-side JS reads it and resubmits it as a separate header/parameter. • Server compares the cookie value to the submitted value -- no server-side session storage needed. • Works because a forged cross-site page can't read the victim's cookie value, even though the browser still attaches the cookie itself automatically. • Weaker than session-stored tokens if the app has any cookie-reading vulnerability (e.g. XSS) elsewhere -- it depends entirely on cookie confidentiality.

APIs and Bearer Tokens: Why the Threat Model Shifts

Classic CSRF depends on one specific browser behavior: cookies are attached to a request AUTOMATICALLY, by the browser, regardless of which page initiated the request. A bearer token sent in an Authorization: Bearer <token> header does not have that property -- the browser never attaches an Authorization header automatically the way it does a cookie. For a malicious page to make a request carrying a valid bearer token, its own JavaScript would have to explicitly read the token from somewhere and set that header itself, which means the token has to be readable by that malicious page's script in the first place -- and if it's stored in localStorage or a JavaScript variable on the legitimate site, the only way an attacker's page gets at it is through a DIFFERENT vulnerability (typically XSS on the legitimate site), not CSRF. This is the core reason APIs authenticated purely with bearer tokens in headers are largely NOT vulnerable to classic CSRF: there's no browser-automatic credential-attachment for a forged page to piggyback on.

That doesn't mean bearer-token APIs are risk-free, though -- the risk just shifts shape. A CORS misconfiguration (an API that reflects Access-Control-Allow-Origin: * or an attacker-controlled origin, combined with Access-Control-Allow-Credentials: true) can let a malicious page's script make a cross-origin request AND read the response, effectively reintroducing a CSRF-shaped hole through a completely different mechanism -- the fix there is a strict, explicit origin allowlist, never a wildcard combined with credentials. And SSRF-adjacent risk exists in the opposite direction: if the API itself makes outbound requests using a bearer token (calling another internal service on the user's behalf) and that outbound destination is influenced by attacker-controlled input, the token can end up sent somewhere the developer never intended -- a variant of the SSRF risk covered in the injection-and-traversal lesson, just with a bearer token riding along instead of the server's own ambient credentials. In short: bearer tokens sidestep classic cookie-driven CSRF, but a sloppy CORS policy or an SSRF-shaped bug can hand an attacker functionally the same outcome through a different door.

• Bearer tokens in an Authorization header are NOT automatically attached by the browser the way cookies are -- classic CSRF mostly doesn't apply to them. • For a malicious page to use a bearer token, it would need to read the token itself first -- normally only possible via a separate vulnerability like XSS, not CSRF. • A CORS misconfiguration (wildcard origin + credentials) can reintroduce a CSRF-shaped hole for bearer-token APIs through a different mechanism entirely. • SSRF-adjacent risk: if the API makes outbound requests carrying the bearer token to an attacker-influenced destination, the token can leak to an unintended service. • Cookie-authenticated endpoints (even ones an API also exposes) still need synchronizer tokens / SameSite -- the bearer-token exemption is specific to header-based auth.

Code Examples

csrfToken() generates one unguessable token per session (only once -- reused across that session's forms) and csrfVerify() checks it with hash_equals(), never a plain == comparison, closing the timing side-channel. The token travels as a hidden form field, invisible to a cross-site page that has no way to read the victim's session and therefore cannot forge a request carrying a valid token.

hash_equals($expected, $submitted) SameSite=Lax means this cookie is still attached when a user clicks an ordinary link INTO the site from somewhere else (so login state survives normal navigation), but it is withheld on the cross-site POST/PUT/DELETE and embedded-resource requests that CSRF attacks rely on -- a single configuration line that blocks most practical CSRF attempts as a complement to the synchronizer token above.

No server-side session storage is needed here -- the server only checks that the value the browser sent back in the cookie matches the value the client-side JavaScript separately supplied in the header. A forged cross-site page still gets the victim's cookie attached automatically, but it cannot READ that cookie's value (same-origin policy) to also put it in the header, so the two values can never match on a genuinely forged request.

Common mistake: Making the double-submit cookie HttpOnly -- that would prevent the legitimate client-side JavaScript from reading it too, breaking the pattern entirely; this one specific cookie must be readable by JS by design.

<?php
session_start();

function csrfToken(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function csrfVerify(?string $submitted): bool
{
    $expected = $_SESSION['csrf_token'] ?? '';
    return $submitted !== null
        && $expected !== ''
        && hash_equals($expected, $submitted); // constant-time comparison
}

// -- Rendering the form --
// <form method="post" action="/transfer">
//   <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrfToken()) ?>">
//   ... other fields ...
// </form>

// -- Handling the submission --
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!csrfVerify($_POST['csrf_token'] ?? null)) {
        http_response_code(403);
        exit('Invalid or missing CSRF token');
    }
    // token verified -- safe to perform the state-changing action
}
<?php
// Set BEFORE session_start() so the flags apply to the session cookie itself.
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'secure'   => true,     // only sent over HTTPS
    'httponly' => true,     // not readable from JavaScript
    'samesite' => 'Lax',    // withheld on cross-site POST and embedded requests
]);
session_start();
<?php
// -- Server: set the token as a readable cookie (not HttpOnly -- JS must read it) --
if (empty($_COOKIE['csrf_token'])) {
    $token = bin2hex(random_bytes(32));
    setcookie('csrf_token', $token, [
        'secure'   => true,
        'samesite' => 'Strict',
        'httponly' => false, // client-side JS needs to read this one
    ]);
    $_COOKIE['csrf_token'] = $token; // usable later in this same request
}

// -- Server: verify the header-submitted value matches the cookie value --
function verifyDoubleSubmit(): bool
{
    $cookieToken = $_COOKIE['csrf_token'] ?? '';
    $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    return $cookieToken !== '' && hash_equals($cookieToken, $headerToken);
}

// -- Client-side JS reads the cookie and resends it as a header --
// fetch('/api/transfer', {
//   method: 'POST',
//   headers: { 'X-CSRF-Token': getCookie('csrf_token') },
//   body: JSON.stringify({ amount: 100 }),
// });

Forged Request vs. Legitimate Request

The cookie rides along either way -- the CSRF token is the piece the forging page can never obtain.

Legitimate request (from your own page):
    Browser --cookie (auto)+ CSRF token (from your form)--> Server
    Server checks: cookie valid? YES.  token matches session? YES.  -> ALLOWED

  Forged request (from evil.example's page):
    Browser --cookie (auto, browser doesn't know it's 'foreign')--> Server
    evil.example's JS CANNOT read your session or your cookie value,
    so it has no valid CSRF token to attach.
    Server checks: cookie valid? YES.  token matches session? NO/MISSING. -> REJECTED

Important Rules

Generate one random, unguessable token per session using random_bytes(); never a predictable value.

Embed the token in every state-changing form and verify it server-side before performing the action.

Compare tokens with hash_equals(), never == or === (timing side-channel).

Set SameSite=Lax (or Strict where acceptable) on session cookies as a complementary, largely-free defense.

Use the double-submit-cookie pattern only when session-stored tokens are impractical, and never make that specific cookie HttpOnly.

For bearer-token APIs, classic CSRF mostly doesn't apply -- focus instead on a strict CORS origin allowlist and validating any outbound-request destinations the token might travel to.

Why This Matters in Practice

CSRF was common enough in older web applications that browser vendors eventually built SameSite=Lax as the DEFAULT cookie behavior (rather than an opt-in) specifically to blunt it at the platform level -- but synchronizer tokens remain standard practice in every major PHP framework's form-handling layer because SameSite alone depends on every visiting browser behaving correctly, and defense in depth means not staking a state-changing action on exactly one control. The bearer-token distinction matters just as much in practice: teams migrating a cookie-authenticated app to a token-based API sometimes rip out CSRF protection everywhere, correctly for the token-authenticated endpoints but incorrectly for any cookie-authenticated ones (like an admin panel) still living alongside the API.

if ($_POST['csrf_token'] == $_SESSION['csrf_token']) { /* proceed */ } if (hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'] ?? '')) { /* proceed */ }

A plain == comparison can leak timing information about how many leading characters matched, letting a patient attacker guess the token byte by byte; hash_equals() runs in constant time regardless of where the strings first differ.

[multiple-choice] Why must CSRF tokens be compared with hash_equals() instead of == ? A. hash_equals() is faster to execute B. == does not work with strings in PHP C. A plain == comparison can leak timing information that lets an attacker guess the token character by character; hash_equals() runs in constant time D. hash_equals() also checks the token's expiry automatically

C. A plain == comparison can leak timing information that lets an attacker guess the token character by character; hash_equals() runs in constant time — String comparison operators typically short-circuit at the first mismatched character, and the time that takes can leak how many leading characters were correct -- a timing side-channel. hash_equals() is specifically built to compare in constant time regardless of where a mismatch occurs.

[multiple-choice] Why does classic CSRF mostly not apply to an API authenticated purely with a bearer token in the Authorization header? A. Bearer tokens are always encrypted B. Bearer tokens expire too quickly to be misused C. The browser does not automatically attach the Authorization header the way it does cookies, so a forging page has no way to make the browser send a valid token on its behalf D. APIs never accept POST requests

C. The browser does not automatically attach the Authorization header the way it does cookies, so a forging page has no way to make the browser send a valid token on its behalf — Classic CSRF relies on the browser AUTOMATICALLY attaching a credential (a cookie) to any request regardless of origin. A bearer token in a header isn't attached automatically by the browser -- a malicious page's own script would have to already have the token to add that header, which is a different problem (like XSS), not CSRF.

[true-false] True or False: SameSite=Lax cookies alone are considered sufficient CSRF protection with no need for synchronizer tokens. A. True B. False

B. False — False -- SameSite is a strong complementary defense, but its protection depends on the visiting browser correctly implementing and applying it. Synchronizer tokens remain the application-level defense that doesn't depend on browser behavior, and the two are meant to be layered together.

An application exposes both a cookie-authenticated admin panel and a separate bearer-token-authenticated public API on the same domain. Explain which one needs synchronizer-token CSRF protection and why the other one's exposure is different.

The cookie-authenticated admin panel needs full synchronizer-token CSRF protection (plus SameSite cookies) because the browser attaches its session cookie automatically to any request, including one forged by a malicious page. The bearer-token API is not vulnerable to classic CSRF the same way, since a forged page's JavaScript would need to already possess the bearer token to attach it -- which it can't get through CSRF alone. That API's real exposure is a misconfigured CORS policy (which could let a malicious page's script make an authenticated cross-origin request and read the response) and any endpoint where the token is used to make outbound requests to a destination influenced by user input (an SSRF-adjacent risk).

Ask which credential the BROWSER attaches automatically, without any explicit action from the requesting page's JavaScript.