Cybersecurity / Networking

CSRF Token Missing or Incorrect in SPA Frontend Requests

CSRF token errors in an SPA almost always come down to the frontend not correctly obtaining the current token before making a request, or not attaching it in the exact format and location the backend expects — the backend-side CSRF validation itself is usually correctly implemented; the gap is typically in how the frontend integrates with it.

The Problem

A mutating request (POST, PUT, DELETE) from your SPA frontend fails with a CSRF-related error:

{
  "error": "CSRF token missing or incorrect"
}
403 Forbidden - Invalid CSRF token

The same backend endpoint might work fine when tested directly with a tool like Postman that isn't subject to the same CSRF protection flow, which is expected — CSRF protection is specifically about preventing browser-based cross-site requests, not about blocking API tools directly.

Why It Happens

CSRF protection typically works by requiring a token — obtained from the server, usually via a cookie or an initial GET request — to be included in every subsequent mutating request, proving the request genuinely originated from your own frontend rather than a malicious third-party site. Common integration gaps:

  • The frontend never actually fetches the CSRF token from the backend before attempting mutating requests, or fetches it once and then the session/token expires or rotates without the frontend re-fetching an updated value
  • The token is correctly obtained but sent in the wrong location — as a request body field when the backend expects a specific header, or vice versa
  • The token is stored in a JavaScript-accessible cookie, but the actual value used to attach to requests is read incorrectly, or from a stale cached value rather than the current one
  • CORS and credential settings on the frontend's requests aren't correctly configured to include cookies, so even if a CSRF cookie exists, it's never actually sent along with the request at all

The Fix

First, confirm exactly how your backend expects the CSRF token to be delivered — check its documentation or implementation for the specific header name or field it validates against:

// Common pattern: token in a specific custom header
X-CSRF-Token: <token-value>

Fetch the current token from the backend explicitly before making mutating requests, rather than assuming a previously obtained value is still valid:

async function getCsrfToken() {
  const response = await fetch('/api/csrf-token', { credentials: 'include' });
  const data = await response.json();
  return data.csrfToken;
}

Attach the token consistently to every mutating request, in exactly the location the backend expects:

const token = await getCsrfToken();
await fetch('/api/orders', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token,
  },
  body: JSON.stringify(orderData),
});

The credentials: 'include' option is critical if your CSRF mechanism relies on a cookie at all — without it, the browser won't send cookies along with a cross-origin (or even same-origin, depending on configuration) fetch request, meaning the server never receives the session context needed to validate the token correctly, regardless of how correctly the token header itself is set.

Centralize token fetching and attachment logic in a single shared API client or interceptor, rather than repeating this logic separately in every individual request throughout your codebase, which is both more maintainable and reduces the chance of a specific request accidentally omitting the token due to inconsistent manual implementation:

// Example: axios interceptor pattern
axios.interceptors.request.use(async (config) => {
  if (['post', 'put', 'delete', 'patch'].includes(config.method)) {
    config.headers['X-CSRF-Token'] = await getCsrfToken();
  }
  return config;
});

If your token has a limited lifetime or rotates periodically, handle the specific case of a request failing due to an expired token by automatically re-fetching a fresh token and retrying once, rather than requiring the user to manually reload the page to recover:

if (response.status === 403 && isCsrfError(response)) {
  const freshToken = await getCsrfToken();
  // retry the original request with the fresh token
}

Still Not Working?

If the token is confirmed to be correctly fetched and attached in the expected location, check whether your frontend and backend are actually running on different origins during development (a common setup with separate dev servers for frontend and backend on different ports) without CORS configured to properly support credentialed cross-origin requests — CSRF protection cookies specifically require careful SameSite cookie attribute configuration to work correctly across genuinely different origins, and a cookie set with SameSite=Strict simply won't be sent at all in a cross-origin development setup, producing a CSRF failure that has nothing to do with your token-fetching code being wrong.