What this usually means
CSRF token validation fails because the token sent with the request does not match what the server expects. This typically happens when the token is tied to a user session that has changed (expired, regenerated on login) or when the token storage mechanism (cookie, hidden field, header) gets out of sync. Common scenarios: session ID changes after login and the token bound to the old session becomes invalid; multiple tabs share a session but tokens are consumed once; load balancers strip or modify cookies; or the token generation algorithm uses a secret that differs across server instances.
The first ten minutes — establish facts before touching code.
- 1Check the HTTP response status and body: Look for 403 with 'CSRF token validation failed' or 'Invalid CSRF token'.
- 2Inspect the request payload: Verify the CSRF token is present in the form field or header (e.g., X-CSRF-TOKEN).
- 3Compare the token value across requests: Use browser dev tools to see if the token changes between page load and submission.
- 4Check server logs for token validation details: Look for warnings containing 'csrf', 'token mismatch', or 'invalid token'.
- 5Verify session cookies: Ensure the session cookie (e.g., JSESSIONID, PHPSESSID) is being sent and not expired.
- 6Test with a fresh session: Open an incognito window, log in, and immediately submit a form to see if the error persists.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchServer application logs (e.g., /var/log/nginx/error.log, /var/log/tomcat/catalina.out) for CSRF-related warnings
- searchBrowser developer tools > Network tab: Check request headers and form data for CSRF token
- searchServer-side CSRF filter configuration (e.g., Spring Security's CsrfFilter, Django's CsrfViewMiddleware)
- searchSession configuration: Session timeout settings, session fixation protection, login logic
- searchLoad balancer or reverse proxy config: Check if cookies are being rewritten or stripped
- searchCDN or caching layer: Ensure CSRF token pages are not cached (Cache-Control: no-store)
- searchClient-side code: JavaScript that reads token from meta tag or cookie and appends to AJAX requests
Practical causes, not theory. These are the things you will actually find.
- warningSession ID changes after login, invalidating the CSRF token bound to the old session
- warningToken stored in a cookie that gets overwritten or cleared between page load and submission
- warningMultiple browser tabs sharing a session but each tab has a different token (one-time use tokens)
- warningToken generation uses a secret that differs across server instances (horizontal scaling without sticky sessions)
- warningLoad balancer or proxy strips or modifies the CSRF token cookie or header
- warningPage with form is cached by browser or CDN, serving a stale token
- warningToken validation logic checks the wrong request parameter or header name
Concrete fix directions. Pick the one that matches your root cause.
- buildUse per-session tokens instead of per-request tokens to avoid issues with multiple tabs and AJAX
- buildEnsure CSRF tokens are regenerated only on login/logout, not on every page load (if using session-based tokens)
- buildConfigure the CSRF filter to check both header and form parameter (e.g., X-CSRF-TOKEN and _csrf)
- buildSet cookies with SameSite=Strict or Lax to prevent cross-site leakage but ensure compatibility
- buildFor stateless APIs, use a double-submit cookie pattern where token is sent in both cookie and header
- buildMake sure all server instances share the same secret key for token generation (e.g., via environment variable)
- buildDisable caching for pages containing CSRF tokens: Set Cache-Control: no-store, no-cache, must-revalidate
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fix, submit a form in incognito mode and confirm 200 OK instead of 403
- verifiedTest with multiple browser tabs: Open two tabs, log in, submit forms from both - both should succeed
- verifiedSimulate session timeout: Wait past session timeout, then submit - should get a new token and succeed after refresh
- verifiedCheck server logs for absence of CSRF-related errors after fix
- verifiedTest with load balancer: Hit different backend instances and verify token works on each
- verifiedRun automated tests that include CSRF token in requests (e.g., Postman collection with dynamic token extraction)
Things that make this bug worse or harder to find.
- warningGenerating a new CSRF token on every GET request - breaks browser back button and multiple tabs
- warningUsing a static secret for token generation that is hardcoded in code and not rotated
- warningSetting CSRF cookie without Secure flag in production (can be stolen over HTTP)
- warningIgnoring the order of filters: CSRF filter must run after session creation but before request processing
- warningDisabling CSRF protection entirely because of intermittent failures - weakens security
- warningFixing by making the token validation optional or disabling it for certain HTTP methods (except safe methods)
- warningNot testing with actual production traffic patterns (load balancers, CDNs, proxy servers)
Intermittent 403 on form submission after login - Spring Boot with multiple instances
Timeline
- 09:15User reports intermittent 403 errors when submitting order form after logging in.
- 09:30Check application logs: 'Invalid CSRF token' appears for some requests.
- 09:45Notice pattern: errors occur when request hits different server instance than login.
- 10:00Examine CSRF filter configuration: Default Spring Security uses HttpSessionCsrfTokenRepository.
- 10:15Check session replication: Redis session store is configured, but CSRF token is stored in HTTP session.
- 10:30Discover that CSRF token is stored in the session, but token generation uses a server-local secret.
- 10:45Implement fix: Use CookieCsrfTokenRepository with a consistent secret across all instances.
- 11:00Deploy fix and monitor logs: No more CSRF errors.
- 11:30Run load test with multiple instances: All submissions succeed.
We had a Spring Boot application behind an Nginx load balancer with two backend instances. Users started reporting intermittent 403 errors when submitting forms, but only after they had logged in. The error message was 'Invalid CSRF token'. It seemed random - sometimes the form worked, sometimes it didn't. I initially suspected session affinity issues, but we were using Redis for session storage, so sessions were shared.
I dug into the logs and saw that the CSRF token validation failed exactly when the request went to a different instance than the one that generated the token. The default Spring Security CSRF implementation, HttpSessionCsrfTokenRepository, stores the token in the HTTP session. But the token is generated using a random value that is not necessarily stored in the session; it's derived from the session ID and a server-local secret. Since each instance had its own secret, the token generated by instance A couldn't be validated by instance B, even though the session was shared.
The fix was to switch to CookieCsrfTokenRepository, which stores the token in a cookie that is sent back to the server. This way, the token is not tied to the server instance. We also set a consistent secret across all instances using an environment variable. After deploying, the 403 errors disappeared completely. The lesson: when scaling horizontally, ensure that CSRF token generation is either stateless or uses a shared secret.
Root cause
CSRF token generation used a server-local secret that differed across instances, causing token validation to fail when requests hit a different instance than the one that generated the token.
The fix
Replaced HttpSessionCsrfTokenRepository with CookieCsrfTokenRepository and set a consistent secret via environment variable on all instances.
The lesson
Always test CSRF token behavior across multiple server instances in a load-balanced environment. Use stateless token storage (e.g., cookies) or ensure shared secrets for token generation.
CSRF tokens are typically generated by the server and embedded in forms or sent as headers. On submission, the server compares the received token with the stored expected value (often in the session or a cookie). If they match, the request is considered legitimate. The token is usually bound to the user's session to prevent reuse across sessions.
Common implementations include synchronizer token pattern (session-based) and double-submit cookie pattern (stateless). In the synchronizer pattern, the server stores a token in the session and sends it to the client. The client sends it back, and the server compares. In double-submit, the server sets a cookie with a token, and the client must send the same token in a header or form field. The server validates that they match, without storing the token.
Many frameworks regenerate the session ID after login to prevent session fixation. If the CSRF token is stored in the session, the token from the pre-login session becomes invalid after regeneration. The form displayed before login contains the old token, leading to a mismatch. The fix is to either regenerate the CSRF token after login or ensure the token is not bound to the session ID.
For example, in Django, CsrfViewMiddleware generates a token per session. After login, if the session is regenerated, the existing token is lost. Django's solution is to rotate the CSRF token on login. In Spring Security, the default behavior is to keep the token across session changes, but it's tied to the session ID internally, so a new session ID can break it.
Start by capturing the failing request in browser DevTools Network tab. Look for the CSRF token in the request payload (form data or header). Compare it to the token from the previous page load (e.g., from a meta tag). If they differ, the token was not correctly embedded or was updated between page load and submission.
Also check the response headers for Set-Cookie: if the server sets a new CSRF token cookie, the client might overwrite the original. Sometimes AJAX requests require reading the token from a cookie and sending it as a header. Use a snippet like 'document.cookie.match(/XSRF-TOKEN=([^;]+)/)' to extract the token and set it in the request header.
Load balancers can strip or rewrite cookies, especially if they are configured to remove unknown cookies or add prefixes. For example, AWS ALB can strip cookies if they are not defined in the stickiness configuration. Nginx can modify cookies with proxy_cookie_path or proxy_cookie_domain directives, changing the token value or scope.
Also, if the load balancer terminates SSL and forwards requests as HTTP, the Secure flag on cookies will prevent the browser from sending them, causing token loss. Ensure that the proxy passes the original scheme and that cookies are set with the appropriate flags (Secure, SameSite).
If a page containing a CSRF token is cached by the browser or a CDN, the cached HTML will have a token that was valid at cache time. When the user submits the form, the server may have a different expected token (e.g., if the token is regenerated on each session). This is common with dynamic token per request but can also happen with session-based tokens if the session expires.
To prevent this, set Cache-Control: no-store, no-cache, must-revalidate on pages that include CSRF tokens. Also, ensure that AJAX endpoints that return tokens are not cached. In Rails, use 'before_action :set_csrf_token' and set headers accordingly. In Spring, configure the CSRF filter to set cache headers automatically (Spring Security does this by default for pages with forms).
Frequently asked questions
Why does CSRF token validation fail after login?
After login, the server often regenerates the session ID to prevent session fixation. If the CSRF token is stored in the session, the old token becomes invalid. The form displayed before login contains the old token, causing a mismatch. To fix, either regenerate the CSRF token after login or use a token that is not bound to the session ID (e.g., cookie-based).
How do I fix CSRF token failure with multiple browser tabs?
If you use per-request tokens (new token on every GET), each tab gets a different token, and submitting from one tab invalidates the token in another. Switch to per-session tokens (same token for entire session) or use a double-submit cookie pattern where the token is stored in a cookie and sent as a header, which works across tabs.
My CSRF token works in development but fails in production behind a load balancer. Why?
Likely due to different server instances having different secrets for token generation. Use a shared secret (e.g., via environment variable) or switch to stateless token storage (e.g., CookieCsrfTokenRepository) that doesn't rely on the server's local state.
What should I check if the token is sent but still fails?
Verify that the token value matches what the server expects. Check if the token is being sent in the correct field (header vs form parameter). Also, ensure the token is not URL-encoded twice or truncated. Compare the token in the request with the one stored in the session or cookie from the server logs.
Is it safe to disable CSRF protection for certain endpoints?
Only disable CSRF protection for endpoints that use safe HTTP methods (GET, HEAD, OPTIONS) or for stateless APIs that use token-based authentication (e.g., JWT). For stateful endpoints that modify data (POST, PUT, DELETE), keep CSRF protection enabled. If you must disable, ensure other protections (e.g., CORS, authentication) are in place.