Implementing CSRF Protection Correctly
AI generated
OWASP
0x00
Security · CSRF · Web Application Security · Magento 2
Implementing CSRF Protection Correctly
Synchronizer tokens, SameSite, and Magento's form_key working together

Running forms and state-changing endpoints without real CSRF protection means relying solely on the browser's session cookie, opening the door to forged requests from other sites. Synchronizer tokens, double-submit cookies, the SameSite attribute, and Magento's form_key show how to close that gap reliably.

16 min. read Synchronizer Token · SameSite · form_key OWASP · Magento 2.4.8 · REST APIs

1. What CSRF is and how it exploits automatic cookie inclusion

Cross-Site Request Forgery (CSRF) exploits a fundamental browser behavior that normal usage depends on: on every request to a domain, the browser automatically attaches all matching cookies, regardless of which page triggered the request. If a user is logged into an application and holds a valid session cookie, the browser sends that cookie along even when the request originates from a completely unrelated website. The server ultimately just sees an authenticated request and, without additional checks, cannot tell whether the user deliberately triggered it.

The crucial difference from Cross-Site Scripting (XSS): the attacker never needs to read or steal the session cookie. The same-origin policy prevents foreign JavaScript from reading the response of another domain, but it does not prevent a request from being sent in the first place. It is precisely this gap between "reading a response" and "sending a request" that makes CSRF a distinct threat requiring its own defense strategy, independent of XSS mitigations like Content Security Policy or output escaping.

2. Anatomy of a CSRF attack

A typical CSRF attack unfolds conceptually in three steps, without the target application needing a vulnerability like XSS at all. First: a victim logs into a target application, for example an online store's admin backend, and receives a session cookie. Second: while the session is still active, the victim visits a page controlled or compromised by an attacker. Third: that page contains a hidden form or an automatically firing request pointing at a state-changing URL of the target application, for example an endpoint that changes an email address or creates a new admin user.

As soon as the victim's browser fires this request, it automatically attaches the valid session cookie, and the target application's server carries out the action on the victim's behalf without the victim noticing anything. Important for understanding this: this walkthrough describes the attack pattern purely conceptually, to help frame defenses, not as a working how-to. What matters is that cookie-based authentication alone can never confirm that a request was deliberately initiated by the user.

3. The synchronizer token pattern: mechanics and validation

The synchronizer token pattern is the most established CSRF defense. The server generates a cryptographically random, unpredictable token, stores it server-side bound to the active session, and embeds it as a hidden field in every form. When the form is submitted, the browser sends the token along as part of the request body. Because the attacker neither knows this token nor can read it from a foreign page, the same-origin policy prevents exactly that, they cannot forge a valid request, even though the session cookie gets attached automatically.

Correct server-side validation is decisive: the submitted token must be compared against the stored value using a constant-time comparison (such as hash_equals() in PHP), never a simple ==, which would be vulnerable to timing attacks. The token should be regenerated on every login and, ideally, rotated after successful use to make replay attacks via stolen logs or browser history harder. If validation is missing on even a single state-changing endpoint, the entire protection for that path is worthless.


<!-- Hidden CSRF token field embedded in a state-changing form -->
<form method="POST" action="/account/email/update">
    <label for="email">New email address</label>
    <input type="email" id="email" name="email" required>

    <!-- Synchronizer token bound to the current server-side session -->
    <input type="hidden" name="csrf_token" value="a1b2c3d4e5f6...">

    <button type="submit">Update email</button>
</form>

4. Double-submit cookie as a stateless approach

The double-submit cookie pattern solves the same problem without server-side session storage for the token. The server sets a random token as a cookie, and the same token must additionally be sent as a request parameter or custom header. The server only checks whether both values match. Because an attacker cannot read a foreign domain's cookie value due to the same-origin policy in order to copy it into the parameter, a forged request fails on the mismatch. This approach works especially well for stateless APIs and distributed systems where no central session store exists.

The simple variant has a known weakness: if an attacker can set a cookie with the same name via a subdomain or an insecure cookie configuration (cookie tossing), the double-check can be defeated. The more robust variant therefore additionally signs the token with a server-side secret (HMAC), so a smuggled-in cookie without a valid signature stands out immediately. Combined with __Host- cookie prefixes, which technically prevent cross-domain cookie setting, the mechanism becomes significantly more robust.


<?php

declare(strict_types=1);

/**
 * Validates a double-submit CSRF token using an HMAC-signed cookie value.
 * Compares the signed cookie value against the value sent in the request header.
 */
final class DoubleSubmitCsrfValidator
{
    public function __construct(private readonly string $hmacSecret)
    {
    }

    public function isValid(?string $cookieToken, ?string $headerToken): bool
    {
        if ($cookieToken === null || $headerToken === null) {
            return false;
        }

        // Constant-time comparison prevents timing side-channel attacks
        if (!hash_equals($cookieToken, $headerToken)) {
            return false;
        }

        [$rawToken, $signature] = array_pad(explode('.', $cookieToken, 2), 2, '');
        $expectedSignature = hash_hmac('sha256', $rawToken, $this->hmacSecret);

        return hash_equals($expectedSignature, $signature);
    }
}

5. The SameSite cookie attribute as a complementary defense

The SameSite attribute instructs the browser not to send cookies on cross-site requests at all. Strict blocks the cookie completely on any cross-site navigation attempt, even a plain link click from a foreign page, which can make sense for session cookies in security-critical applications but limits the user experience. Lax, today's default in modern browsers, allows the cookie on simple top-level navigations like GET links, but blocks it on POST form submits and on embedded resources such as iframes or images. None disables the restriction entirely and strictly requires the Secure attribute.

SameSite is explicitly not a replacement for token-based CSRF protection, but a complementary layer of defense. Older browsers ignore the attribute entirely and send cookies as usual. Subdomain attacks, where an attacker controls a different subdomain of the same registrable domain, still count as "same-site" under some browser implementations and bypass the protection. And GET-based state changes, which exist against HTTP semantics, still fire under Lax. Defense in depth means SameSite meaningfully reduces the attack surface but never replaces explicit token validation.


# Nginx: enforce Secure and SameSite attributes on the session cookie
# proxied from the application, as a defense-in-depth layer alongside
# explicit CSRF token validation in the application code
proxy_cookie_flags session_id secure samesite=lax;

# PHP: configure session cookie params before session_start()

<?php

declare(strict_types=1);

// Configure the session cookie with SameSite as a complementary defense,
// not as a replacement for synchronizer token validation
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '.example.com',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

6. Why GET requests must never have side effects

The HTTP specification defines GET as a safe method that must not trigger side effects on the server, and as idempotent, meaning it can be repeated any number of times with the same result. Browsers, proxies, crawlers, and prefetching mechanisms rely on this guarantee and automatically fire GET requests without asking, for example when preloading links, reloading a page, or indexing content via search engine bots. Historically well-known CSRF incidents exploited exactly this: a simple <img src="/account/delete?id=42"> on a foreign page was enough to trigger a deletion because the endpoint incorrectly responded to GET.

Consistently restricting state-changing operations to POST, PUT, PATCH, or DELETE removes the basis for CSRF attacks via simple links, images, or prefetching from the outset, because those mechanisms only ever trigger GET. This is not a complete CSRF defense, since POST requests can also be forged via auto-submitting forms, but it is the absolute baseline requirement. REST-compliant API designs that strictly treat GET as read-only rule out an entire class of trivial attacks before token validation even comes into play.

7. Magento's form_key CSRF protection in detail

Magento implements the synchronizer token pattern via the so-called form_key. At session start, Magento\Framework\Data\Form\FormKey generates a random token and stores it in the session. In every form, the value is rendered as a hidden field via $block->getFormKey() or the built-in form_key.phtml template. For classic controllers extending Magento\Framework\App\Action\Action, Magento\Framework\Data\Form\FormKey\Validator automatically checks on POST requests whether the submitted form_key matches the session value, unless the controller explicitly overrides _isAllowed() without this check.

Since Magento 2.3, newer REST and controller endpoints additionally use Magento\Framework\App\CsrfAwareActionInterface, which mandates createCsrfValidationException() and validateForCsrf(). The most common mistake in new controllers: implementing validateForCsrf() with an unreflected return true;, because a developer wanted to "temporarily" disable the check, leaves the endpoint permanently unprotected. Equally often forgotten: custom AJAX controllers that don't extend the standard action class but implement HttpPostActionInterface directly must wire in CSRF validation explicitly themselves, they do not inherit it automatically.


<?php

declare(strict_types=1);

namespace Mironsoft\Example\Controller\Adminhtml\Item;

use Magento\Backend\App\Action;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;

/**
 * Controller demonstrating explicit CSRF validation for a new admin endpoint.
 * Extending Action alone is not sufficient once CsrfAwareActionInterface
 * is implemented explicitly; validateForCsrf() must perform a real check.
 */
class Delete extends Action implements HttpPostActionInterface, CsrfAwareActionInterface
{
    /**
     * Rejects the request with a CSRF exception when validation fails.
     *
     * @param RequestInterface $request
     * @return InvalidRequestException|null
     */
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    /**
     * Performs the actual form_key comparison against the session value.
     * Returning true unconditionally here disables CSRF protection entirely.
     *
     * @param RequestInterface $request
     * @return bool|null
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        // Delegate to the framework's default form_key validation logic
        return null;
    }

    /**
     * @return \Magento\Backend\Model\View\Result\Redirect
     */
    public function execute()
    {
        // Item deletion logic runs only after CSRF validation has passed
        return $this->resultRedirectFactory->create()->setPath('*/*/');
    }
}

8. CSRF protection for AJAX, SPAs, and API endpoints

Even AJAX requests originating from the same origin carry the session cookie automatically and therefore need the same CSRF protection as classic forms. The common pattern: the server renders the token into a <meta> tag or a dedicated JSON configuration during the initial page build, the frontend reads it and sends it on every state-changing request as a custom header, for example X-CSRF-Token, instead of in the body. The decisive advantage of custom headers over body parameters: custom headers simply cannot be set from plain HTML without JavaScript, so a simple HTML form on an attacker's page is structurally unable to forge them.

For single-page applications, a dedicated token endpoint is recommended, called at app startup or after login and returning the current token, combined with rotation after every sensitive operation. Pure API endpoints for third-party systems that authenticate via API keys or OAuth bearer tokens instead of cookies generally don't need CSRF protection, because the browser never automatically attaches bearer tokens from Authorization headers, which is exactly the difference from cookie-based authentication that makes CSRF possible in the first place.


// Read the CSRF token from a meta tag rendered by the server
// and send it as a custom header on every state-changing fetch request
function getCsrfToken() {
  const meta = document.querySelector('meta[name="csrf-token"]');
  return meta ? meta.getAttribute('content') : null;
}

async function updateAccountEmail(email) {
  const response = await fetch('/api/account/email', {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': getCsrfToken(),
    },
    body: JSON.stringify({ email }),
  });

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }

  return response.json();
}

9. CSRF defense mechanisms compared

No single measure fully covers every CSRF attack vector. Only the interplay of token validation, clean HTTP method semantics, and cookie attributes produces a resilient defense. The table below contrasts insecure default assumptions with the recommended, secure approaches.

Aspect Insecure Secure approach Rationale
Checking authentication Valid session cookie alone as proof Session cookie + synchronizer token A cookie alone doesn't confirm a deliberate user action
Triggering a state change Via GET links like /delete?id=42 Exclusively via POST/PUT/DELETE GET is fired automatically by browsers and bots
Token comparison Simple == string comparison hash_equals(), constant time Prevents timing-based side-channel attacks
Cookie configuration SameSite=None with no further check SameSite=Lax/Strict + token validation SameSite complements token protection, doesn't replace it
AJAX token transport Token only in the request body, like a normal form field Custom header like X-CSRF-Token Custom headers cannot be set without JavaScript
Magento controller validateForCsrf() hard-returns true Real form_key check via FormKey\Validator A hardcoded true permanently disables protection

Mironsoft

Web application security, code audits, and Magento hardening

Ready to close your CSRF gaps for good?

We audit your forms, AJAX endpoints, and Magento controllers for missing or broken CSRF validation, and implement token patterns, SameSite configuration, and form_key hardening properly.

CSRF audit

Systematic review of every state-changing endpoint and controller

Magento hardening

Retrofitting correct form_key and CsrfAwareActionInterface implementations

API hardening

Introducing token header patterns for AJAX, SPAs, and REST endpoints

10. Summary

Implementing CSRF protection correctly means accepting the browser's automatic cookie inclusion as a given and never mistaking it for proof of a deliberate user action. The synchronizer token pattern remains the most reliable method because it requires a value that an attacker fundamentally cannot guess or read. Double-submit cookies offer a stateless alternative for distributed systems, as long as they are additionally signed. SameSite meaningfully reduces the attack surface but never replaces explicit validation in application code.

Just as important as the token mechanics is a strict separation between read and write HTTP methods: GET must never trigger side effects. In Magento, the form_key handles this automatically for classic controllers, but every new custom controller and every AJAX endpoint must implement validation explicitly and correctly, rather than accidentally disabling it with a hardcoded true.

Implementing CSRF Protection Correctly, the Essentials at a Glance

Synchronizer token

Server generates an unpredictable per-session token, validated via hash_equals(), never with ==.

SameSite as a complement

Lax or Strict reduces the attack surface but never replaces token validation in code.

GET stays read-only

State changes exclusively via POST/PUT/DELETE, never triggerable via GET links or image tags.

Magento form_key

FormKey\Validator for standard controllers, correctly implement CsrfAwareActionInterface for custom endpoints.

11. FAQ: Implementing CSRF Protection Correctly

1What is CSRF and how does it differ from XSS?
CSRF forces a logged-in user's browser to unknowingly send a request, with the session cookie attached automatically. XSS instead injects foreign code to read or manipulate data.
2Why can the attacker trigger the request without knowing the cookie?
The browser automatically attaches cookies to every matching request, no matter which page triggered it. The attacker doesn't need to read the cookie, only get the browser to send it.
3Is SameSite=Lax enough as the sole CSRF protection?
No. Older browsers ignore the attribute, certain subdomain setups can bypass it, and GET-based state changes remain allowed under Lax. Just a complementary layer, not a substitute for token validation.
4How exactly does the synchronizer token pattern work?
The server generates a random token, stores it bound to the session, and embeds it in every form. On submission a constant-time comparison runs against the stored value.
5Difference between a synchronizer token and a double-submit cookie?
The synchronizer token is stored server-side in the session. Double-submit cookie stores no state, instead comparing a cookie against a request header, ideally additionally HMAC-signed.
6Why must GET requests never have side effects?
GET is defined as a safe, idempotent method. Browsers, crawlers, and prefetching fire GET automatically. A side effect in GET can already be triggered by a simple image tag.
7How does Magento's form_key CSRF protection work?
FormKey generates a token at session start, renders it in forms, and checks it against the session value via FormKey\Validator on POST requests. Since 2.3, CsrfAwareActionInterface handles this for newer controllers.
8Most common mistake in new Magento controllers regarding CSRF?
Implementing validateForCsrf() with a hardcoded return true and forgetting to fix it before deployment. Custom AJAX controllers without the standard base class often forget validation entirely.
9How do you protect AJAX/SPA endpoints against CSRF?
Render the token into a meta tag or serve it via a dedicated endpoint, then send it on every request as a custom header like X-CSRF-Token. Plain HTML cannot set custom headers.
10Does CORS automatically protect against CSRF?
No. CORS only governs whether foreign JavaScript may read the response, not whether the request gets sent. An HTML form without JavaScript is not subject to any CORS check.