Reader Stacks

Fixing the TokenMismatchException on an AJAX Request in Laravel

The CSRF token embedded in a page's initial HTML goes stale the moment the session it was generated for expires — a long-open browser tab is the single most common real-world trigger for this exact exception.

TokenMismatchException on an AJAX request almost always means the CSRF token Laravel expects doesn't match the one actually sent — usually because the token was never included in the request at all, or because it's gone stale relative to the current session.

The root cause: Laravel protects every POST/PUT/PATCH/DELETE request by default

The VerifyCsrfToken middleware, active on the web middleware group by default, rejects any state-changing request (anything other than GET/HEAD/OPTIONS) that doesn't include a valid CSRF token matching the current session — this applies to an AJAX request exactly the same way it applies to a normal form submission.

The fix: including the token in every AJAX request via jQuery's ajaxSetup

$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});

Setting this once via ajaxSetup(), typically in a global JS file loaded on every page, automatically attaches the CSRF token header to every subsequent $.ajax() call made afterward — simpler than manually adding the header to every individual AJAX call throughout the codebase.

Including the token in a raw fetch() call instead

fetch('/products', {
    method: 'POST',
    headers: {
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
});

Including it as a hidden form field, for a traditional (non-AJAX) form

@csrf

The @csrf Blade directive renders a hidden input containing the token — this is the standard approach for a normal form submission, distinct from the header-based approach needed for an AJAX request, since a traditional form has no custom headers to attach a token to.

Why the exception shows up specifically after a long-idle browser tab

The CSRF token is tied to the current session, and a session eventually expires based on its configured lifetime — a browser tab left open for longer than the session lifetime still has the *old* token baked into its already-loaded HTML, so any AJAX request it sends afterward carries a now-stale token that no longer matches the (expired or renewed) session, producing exactly this exception; this is the single most common real-world trigger.

A JavaScript-side fix for the long-idle-tab scenario

$(document).ajaxError(function (event, xhr) {
    if (xhr.status === 419) { // Laravel's CSRF token mismatch status code
        alert('Your session has expired. The page will now reload.');
        window.location.reload();
    }
});

Laravel returns HTTP 419 specifically for a CSRF token mismatch — catching this status code globally and prompting a page reload (which fetches a fresh token embedded in the newly loaded HTML) is a reasonable user-facing fallback for the stale-tab scenario, rather than leaving the user looking at a confusing generic error.

Excluding a specific route from CSRF protection entirely

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'webhooks/*',
    ]);
})

This should be reserved specifically for routes that genuinely can't include a CSRF token at all — an incoming webhook from a third-party service, which has no access to the site's session or token — never as a general workaround for an AJAX request that could otherwise include the token correctly.