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:
SameSiteAttribute Mismatch: Browsers rejectSameSite=Noneif the attribute is not uppercase or if theSecureflag is missing. Furthermore, older Java versions or Spring Boot defaults sometimes injectSameSite=Laxeven 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 theXSRF-TOKENcookie 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=Noneis treated asLaxif theSecureflag is not present or if theSameSitevalue is not exactlyNone. - Environment Drift: Local environments often run on
localhost(SameSite=Lax allowed). Production environments (Vercel/Render) run on distinct domains (e.g.,app.vercel.appvsapi.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, andDELETErequests 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
SameSitepolicy, 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
- Standardize Cookie Serialization: Implement a
CookieSerializerbean immediately to override Spring’s defaults, explicitly settingSameSite=NoneandSecure=truefor production. - Restore CSRF: Re-enable CSRF in the
SecurityFilterChainusingCookieCsrfTokenRepository. This creates theXSRF-TOKENcookie (SameSite=Lax/Strict) which allows the browser to send the cookie back for the initial state loading, and validates the subsequent POST request. - Verify HTTPS: Ensure the backend application is serving over HTTPS (Render does this automatically).
SameSite=Nonewill not work over HTTP. - Debug via Network Tab: Inspect the Request Headers in the browser’s Network tab. Look for the
Cookieheader. 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
localhostor127.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 HTTPSet-Cookieheader.