Spring Boot JWT cookies not sent cross-site from React frontend on Vercel -> Render backend (403 Forbidden)

Summary

A production outage occurred where a React frontend on Vercel could not authenticate against a Spring Boot backend on Render, resulting in 403 Forbidden errors. The root cause was misconfigured SameSite cookie attributes and missing CSRF protection. While SameSite=None was intended, it requires the Secure attribute and explicitly requires SameSite=None (case-sensitive). More critically, disabling CSRF protection while using stateful session cookies for authentication bypassed the standard security handshake (Stateful CSRF tokens), leaving the API unprotected. The immediate fix involves forcing Spring Security’s SameSite attribute to None (via a CookieSerializer bean) and re-enabling CSRF protection to generate the required XSRF-TOKEN cookie and validate the corresponding header.

Root Cause

The 403 errors stemmed from two distinct issues interacting to break the authentication flow:

  • SameSite Attribute Mismatch: Browsers reject SameSite=None if the attribute is not uppercase or if the Secure flag is missing. Furthermore, older Java versions or Spring Boot defaults sometimes inject SameSite=Lax even when configured otherwise.
  • Disabled CSRF Protection: The backend disabled CSRF protection (csrf().disable()), assuming JWT cookies were sufficient. However, Spring Security’s default behavior (when CSRF is enabled) allows requests if the XSRF-TOKEN cookie exists and a matching header is sent. By disabling it, the endpoint became a “naked” protected resource that rejected valid cookies because no mechanism existed to bypass the strict SameSite cross-site restrictions.

Why This Happens in Real Systems

  • Strict Browser Enforcement: Modern browsers (Chrome 80+, Safari 12.1+) enforce First-Party Sets aggressively. SameSite=None is treated as Lax if the Secure flag is not present or if the SameSite value is not exactly None.
  • Environment Drift: Local environments often run on localhost (SameSite=Lax allowed). Production environments (Vercel/Render) run on distinct domains (e.g., app.vercel.app vs api.onrender.com), triggering strict Cross-Site rules that are invisible in local testing.
  • Stateless vs. Stateful Confusion: The developer intended a stateless flow (JWT in cookie) but configured it statefully (standard browser cookie). Spring Security expects a Stateful CSRF flow for cookie-based sessions. Disabling CSRF breaks the handshake required for cross-site mutations (POST/PUT).

Real-World Impact

  • Total Authentication Failure: All POST, PUT, and DELETE requests fail with 403 Forbidden, rendering the application unusable.
  • Security Vulnerability: Disabling CSRF (csrf().disable()) without a custom validation mechanism exposes the application to Cross-Site Request Forgery attacks. Even if the user isn’t logged in, disabling the filter chain is bad practice.
  • Silent Failures: Cookies may appear in “Application > Storage > Cookies” in DevTools, but the browser simply refuses to attach them to the request headers due to SameSite policy, making debugging difficult.

Example or Code

The fix requires two specific changes in the backend configuration.

1. Force SameSite=None in Spring Boot

Spring Boot 2.6+ introduced SameSite properties, but they can be tricky in cloud environments. The most reliable programmatic fix is a CookieSerializer bean to ensure the attribute is applied correctly.

import org.springframework.boot.web.servlet.server.CookieSameSiteSupplier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CookieConfig {

    @Bean
    public CookieSameSiteSupplier applicationCookieSameSiteSupplier() {
        // Ensures ALL cookies default to SameSite=None; Secure flag is handled separately
        return CookieSameSiteSupplier.ofNone();
    }
}

2. Re-enable CSRF for Cookie Handling

When using cookies for authentication, do not disable CSRF. Spring Security automatically checks for the XSRF-TOKEN cookie and validates the X-XSRF-TOKEN header. The frontend (Axios) handles this automatically if withCredentials is true and standard defaults are used.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource())) // Your CORS config
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // Sets XSRF-TOKEN cookie
        )
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated()
        );
    return http.build();
}

3. Frontend Verification (Axios)

Ensure Axios is sending cookies. Based on your snippet, withCredentials: true is correct.

const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  withCredentials: true, // Mandatory for sending cookies cross-site
  headers: { 'Content-Type': 'application/json' }
});

// Note: Axios automatically reads the XSRF-TOKEN cookie 
// and sends it back as X-XSRF-TOKEN header if you use standard methods.

How Senior Engineers Fix It

  1. Standardize Cookie Serialization: Implement a CookieSerializer bean immediately to override Spring’s defaults, explicitly setting SameSite=None and Secure=true for production.
  2. Restore CSRF: Re-enable CSRF in the SecurityFilterChain using CookieCsrfTokenRepository. This creates the XSRF-TOKEN cookie (SameSite=Lax/Strict) which allows the browser to send the cookie back for the initial state loading, and validates the subsequent POST request.
  3. Verify HTTPS: Ensure the backend application is serving over HTTPS (Render does this automatically). SameSite=None will not work over HTTP.
  4. Debug via Network Tab: Inspect the Request Headers in the browser’s Network tab. Look for the Cookie header. If it is missing, it’s a SameSite issue. If it is present but the response is 403, it’s an authorization issue (or CSRF if the header is missing).

Why Juniors Miss It

  • “It Works Locally” Fallacy: They test on localhost or 127.0.0.1. Browsers treat localhost as a secure context and often relax SameSite rules, masking the issue.
  • Confusing 403 with AuthN vs AuthZ: Juniors see “403 Forbidden” and assume the JWT is invalid (AuthZ), not realizing the browser didn’t send the cookie at all (AuthN failure due to policy).
  • Copy-Pasting Security Configs: Copying csrf().disable() from tutorials (which are often stateless REST examples using Bearer tokens in headers) without understanding that Cookie = Stateful = CSRF Required.
  • Over-reliance on Properties: Trusting application.properties (e.g., app.security.cookies-same-site) without programmatically verifying the resulting HTTP Set-Cookie header.