When building modern web applications with Craft CMS, you will frequently find yourself moving beyond standard form submissions and into the world of asynchronous requests. Whether you are building a dynamic search bar, a custom dashboard, or a "load more" button, AJAX is the tool of choice. However, if you have Cross-Site Request Forgery (CSRF) protection enabled—which is a security best practice and the default in Craft CMS—you will likely encounter the dreaded 400 Bad Request: The CSRF token could not be verified error.

In this guide, you will learn exactly why this error occurs and how to properly pass CSRF tokens to your JavaScript environment to ensure your AJAX routes are both functional and secure. We will cover implementation strategies for jQuery, the Fetch API, and Axios, while also discussing how to handle the complexities of static caching.

Why CSRF Protection is Essential for AJAX

Cross-Site Request Forgery (CSRF) is an attack that tricks a victim into submitting a malicious request. Because the browser automatically includes cookies (like your Craft session cookie) with every request to a domain, a malicious site could potentially trigger an action on your Craft site without your consent.

Craft CMS prevents this by requiring a unique, secret token to be submitted with every POST request. This token proves that the request originated from your own site and not a third-party script. When you use a standard Twig form, the {{ csrfInput() }} function handles this for you. However, when you switch to AJAX, you are responsible for manually including that token in your request payload or headers.

Step 1: Making the CSRF Token Available to JavaScript

Before your JavaScript can send a CSRF token, it needs to know what the token is and what name Craft expects it to have. By default, Craft looks for a parameter named CRAFT_CSRF_TOKEN (though this can be customized in your general.php config file via the csrfTokenName setting).

To make this data accessible to your frontend scripts, you should inject the token into your page's head or a global JavaScript object using Twig.

You can use the {% js %} tag to define a global window.csrfToken object. This keeps your logic clean and ensures the variables are available as soon as your scripts run.

{# Get CSRF token info in Twig #}
{% set csrfToken = {
    name: craft.app.config.general.csrfTokenName,
    value: craft.app.request.csrfToken,
} %}

{# Pass CSRF token through to JavaScript (via the page head) #}
{% js "window.csrfToken = #{csrfToken|json_encode|raw};" at head %}

Alternatively, if you prefer a more manual script block approach (common in Craft 3 and 4 environments), you can use this syntax:

<script type="text/javascript">
    window.csrfTokenName = "{{ craft.app.config.general.csrfTokenName }}";
    window.csrfTokenValue = "{{ craft.app.request.csrfToken }}";
</script>

Step 2: Sending the Token with Your AJAX Request

Once the token is available in the window object, you need to include it in your AJAX request. Depending on your library of choice, there are several ways to do this.

Using jQuery $.post or $.ajax

If you are using jQuery, you can append the CSRF token directly to your data object before sending the request.

$(function(){
    // Your custom data
    const data = { 
        id: 100, 
        message: "Hello from AJAX"
    };

    // Append the CSRF Token dynamically
    data[window.csrfToken.name] = window.csrfToken.value;

    // Make the AJAX call to your controller action
    $.post('/actions/plugin-handle/controller/action', data, function(response) {
        console.log('Success:', response);
    }).fail(function(xhr) {
        console.error('Error:', xhr.responseText);
    });
});

Using the Fetch API

For modern vanilla JavaScript implementations, the Fetch API is the standard. You can include the CSRF token in a FormData object or as a JSON body parameter.

async function postData(url = '', data = {}) {
    // Create FormData and append the CSRF token
    const formData = new FormData();
    formData.append(window.csrfTokenName, window.csrfTokenValue);

    // Append your other data
    for (const key in data) {
        formData.append(key, data[key]);
    }

    const response = await fetch(url, {
        method: 'POST',
        body: formData,
        headers: {
            'X-Requested-With': 'XMLHttpRequest', // Helps Craft identify it as an AJAX request
        }
    });
    return response.json();
}

Step 3: Handling the Request in the Controller

On the backend, your Craft CMS controller should be set up to handle these requests properly. It is a best practice to enforce that the action only accepts AJAX requests.

namespace modules\utilities\controllers;

use craft\web\Controller;
use yii\web\Response;

class ExampleController extends Controller
{
    public function actionExampleAjax(): Response
    {
        // Ensure it is an AJAX request
        $this->requireAjaxRequest();

        // Your logic here...
        $data = ['status' => 'success', 'message' => 'Token verified!'];

        return $this->asJson($data);
    }
}

Best Practices and Common Pitfalls

1. The Caching Problem (Blitz, Varnish, Cloudflare)

This is the most common reason CSRF implementations fail in production. If you use static page caching (like the Blitz plugin), the CSRF token generated in Twig will be cached. When a new user visits the site, they will receive an expired or invalid token from the cache, causing the AJAX request to fail.

The Solution: Use an uncached dynamic injection. Most caching plugins for Craft provide a way to inject dynamic fragments. Alternatively, you can create a dedicated, non-cached endpoint (like /actions/utilities/get-token) that your JavaScript calls first to fetch a fresh token before making the main POST request.

2. Global Headers for Axios/jQuery

If your site uses many AJAX calls, it is tedious to manually add the token every time. You can set up global headers so that every request automatically includes the token.

For jQuery:

$.ajaxSetup({
    data: {
        [window.csrfTokenName]: window.csrfTokenValue
    }
});

For Axios:

axios.defaults.headers.common['X-CSRF-Token'] = window.csrfTokenValue;

Note: If sending via headers, ensure your Craft configuration or custom logic is set to check headers for the token.

3. Never Disable CSRF for Convenience

While Craft allows you to disable CSRF for specific actions by setting $allowAnonymous = true or modifying the beforeAction method, this is highly discouraged for any action that modifies data. Disabling CSRF opens your users up to security vulnerabilities.

Frequently Asked Questions

Can I disable CSRF for just one specific route?

While technically possible by overriding the beforeAction method in your controller and setting enableCsrfValidation = false, it is not recommended. It is almost always better to pass the token correctly than to bypass security features.

Why does my token expire after a few hours?

CSRF tokens are tied to the user's session. If the session expires or the user logs out in another tab, the token becomes invalid. Ensure your frontend code handles 400 errors gracefully by prompting the user to refresh the page or by fetching a new token.

Does this work with Craft 5?

Yes. The logic for CSRF in Craft 5 remains consistent with Craft 3 and 4. The craft.app.request.csrfToken and craft.app.config.general.csrfTokenName properties are the standard way to access this data across all modern versions of Craft.

Wrapping Up

Integrating CSRF protection with AJAX in Craft CMS is a straightforward process once you understand how the token moves from the server to the client. By making the token available in your global JavaScript scope and ensuring your caching strategy doesn't interfere with token freshness, you can build secure, interactive experiences without running into "Bad Request" errors.

Key takeaways: - Always use {{ craft.app.request.csrfToken }} to get the current valid token. - Pass the token name and value to a global JS object. - Include the token in the body of your POST requests. - Be mindful of static caching and use dynamic injection for tokens where necessary.