CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong
A preflight rejected by Spring Security looks exactly like a CORS misconfiguration, a CSRF token that never reached the browser looks exactly like a bad credential, and a cookie the browser silently refused to store looks exactly like a server that forgot to send it. Verified against Spring Boot 4.1.1 and Spring Security 7.1.1: why MVC-layer CORS cannot fix a security-layer rejection, the bean name that decides whether your configuration is read at all, what csrf.spa() actually assigns, and why SameSite=None without Secure is not a weaker cookie but no cookie.
Three settings, one browser, and a class of bug where the symptom names the wrong layer. A preflight rejected by Spring Security looks exactly like a CORS misconfiguration. A CSRF token that never reached the browser looks exactly like a bad credential. A cookie the browser silently refused to store looks exactly like a server that forgot to send it.
This article is about the parts where those three settings interact, because that is where the days go. Everything in it was produced by running the companion module on Spring Boot 4.1.1 and Spring Security 7.1.1 — every status code, every header, every log line. Where the reference documentation and the bytecode disagree, the bytecode is quoted and so is the transcript.
Versions. JDK 25 (Temurin 25.0.4.1+1) · Spring Boot 4.1.1 · Spring Framework 7.0.9 · Spring Security 7.1.1 · Tomcat 11.0.24. Versions were read from repo1.maven.org/…/maven-metadata.xml, not from release announcements — note that the <release> element in Boot’s own metadata pointed at 4.2.0-M1 while this was written, and a milestone is not a release.
Everything below runs on the cors-csrf module: eleven Spring profiles on one application, thirteen captured transcripts, and twenty-three assertions.
If you are here because…
Symptom
Section
The preflight returns 401 or 403 and I already added @CrossOrigin
CORS is not one setting, and the two places do not talk to each other
A Spring Boot application can be told about CORS in two entirely separate places.
The MVC layer.WebMvcConfigurer.addCorsMappings(..) and @CrossOrigin register a CorsConfiguration with Spring MVC’s handler mappings. It is read inside DispatcherServlet, when a request is matched to a handler method.
The security layer.HttpSecurity.cors(..) puts a CorsFilter into the security filter chain, at order 1000.
The security filter chain runs to completion before DispatcherServlet is entered. So when the security chain rejects a request, MVC’s CORS configuration is not merely ignored — the code that reads it never runs.
A preflight is not a special protocol. It is an OPTIONS carrying two headers, and you can send one with curl:
Note what is not in that command: no -u, no -b. The browser sends no credentials on a preflight. Reproducing it with credentials hides the bug.
Against a chain configured with addCorsMappings and nothing else:
The chain that produced it has eleven filters and none of them is CorsFilter. Full transcript: 01-mvc-only.txt.
The fix is one line, and it is not on the MVC layer:
http.cors(Customizer.withDefaults())
The same application, same addCorsMappings, now answers 200 with the headers the browser wants. The MVC configuration was fine all along. Nothing was reading it.
Read this if carefully — the next section turns on it.CorsFilter.doFilterInternal ends with:
The filter returns on every preflight — whether or not it found a configuration to apply. A preflight that reaches CorsFilter never reaches anything else.
The bean name nobody mentions
There are two lookups involved in getting a CORS configuration into the chain, and they disagree about what they are looking for.
Lookup one decides whether the CORS configurer runs at all. HttpSecurityConfiguration.applyCorsIfAvailable, disassembled from spring-security-config 7.1.1:
By type. And the test is ifle — “branch if less than or equal to zero”. One bean is enough. So is five.
Lookup two decides which source the configurer uses. CorsConfigurer.getCorsConfigurationSource:
By name. The literal string corsConfigurationSource. No bean definition under that name, and it falls through to Spring MVC’s registrations.
Now name your bean anything else:
@Bean
UrlBasedCorsConfigurationSource apiCorsSource() { // not corsConfigurationSource
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("https://spa.example.com"));
configuration.setAllowedMethods(List.of("GET", "POST"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
The type lookup sees it and switches CORS on. The name lookup does not see it and falls back to MVC, which has nothing. And because CorsFilter returns from every preflight regardless, the request never reaches authorization:
Two hundred. No Access-Control-Allow-Origin. The browser blocks the request and reports a CORS error; the access log shows a successful OPTIONS; nothing anywhere is red. The only trace is one DEBUG line:
o.s.web.cors.DefaultCorsProcessor : Skip: no CORS configuration has been provided
Why the fallback never throws. A stock Boot web application already has a CorsConfigurationSource bean you did not write: mvcHandlerMappingIntrospector. HandlerMappingIntrospector implements the interface. It is not a UrlBasedCorsConfigurationSource, so it does not trigger the type lookup — but it is what the MVC fallback returns, which is why this failure is silent rather than a NoSuchBeanDefinitionException. Check with /diag/cors-sources in the companion module.
The documentation says something else about the multi-bean case:
A correction to the reference documentation. It states: “If you have more than one CorsConfigurationSource bean, Spring Security won’t automatically configure CORS support for you, because it cannot decide which one to use.” In 7.1.1 that is not the behaviour. applyCorsIfAvailable tests getBeanNamesForType(..).length with ifle — greater than zero, not exactly one. With two such beans, CORS is configured, and the bean named corsConfigurationSource serves every request. The other one is never consulted. Transcript: 06-two-sources.txt.
Three rules follow. Name the bean corsConfigurationSource, exactly. If you need several, pass them per chain with .cors(c -> c.configurationSource(..)), which bypasses both lookups. And when you do see NoSuchBeanDefinitionException: Failed to find a bean that implements `CorsConfigurationSource`, note that its message names three fixes and not the one that usually applies: rename your bean.
The default that disappears when you move the configuration
Moving CORS from addCorsMappings to a CorsConfigurationSource bean is not a pure relocation. Compare two transcripts from the same application:
CorsRegistration — the builder behind addCorsMappings — defaults maxAge to 1800 seconds. A bare CorsConfiguration leaves it null, and a preflight response with no Access-Control-Max-Age is not cached. The browser preflights every single cross-origin call: two round trips instead of one, forever, with nothing in any log to suggest it.
configuration.setMaxAge(1800L);
One line, and it is the difference between a working API and a working API that feels slow.
Three rejections, one response
DefaultCorsProcessor checks origin, then method, then request headers. All three failures produce this, byte for byte:
Same status, same body, no Access-Control-* header to distinguish them. The only place the difference exists is a DEBUG line, and these three are the fastest CORS diagnosis available:
o.s.web.cors.DefaultCorsProcessor : Reject: 'https://evil.example.com' origin is not allowed
o.s.web.cors.DefaultCorsProcessor : Reject: HTTP 'DELETE' is not allowed
o.s.web.cors.DefaultCorsProcessor : Reject: headers '[authorization]' are not allowed
Turn those two on before doing anything else. Almost every question in this article is answered by one line from one of them.
With that, the status code becomes a diagnosis:
What you see
What it means
401/403, no Access-Control-* at all
No CorsFilter in the chain. Authorization judged the preflight
200, no Access-Control-*
CorsFilter present, found no configuration for this path. Bean name
403, Invalid CORS request
CorsFilter present, rejected origin, method or headers. Read the DEBUG line
404, no Access-Control-*
The path is outside the pattern you registered
200 with Access-Control-Allow-Origin
It worked
The browser reports “blocked by CORS policy” for the first four rows. Two of them are not CORS problems.
The good case is worth recognising too. A simple request — a plain GET with no preflight — runs the whole chain. An unauthenticated one returns 401 carrying the CORS header, because CorsFilter at 1000 already wrote it before AuthorizationFilter at 4200 rejected the request. The SPA’s fetch resolves and the code can read response.status. That is why “my POST fails but my GET returns a readable 401” is a coherent bug report rather than a contradiction.
The wildcard that is legal to configure and illegal to serve
The Fetch standard forbids answering a credentialed request with Access-Control-Allow-Origin: *. Spring enforces it — but not where you would expect. The configuration builds. The context starts. The check happens on the first request:
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain
the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response
header. To allow credentials to a set of origins, list them explicitly or consider using
"allowedOriginPatterns" instead.
at org.springframework.web.cors.CorsConfiguration.validateAllowCredentials(CorsConfiguration.java:552)
at org.springframework.web.cors.CorsConfiguration.checkOrigin(CorsConfiguration.java:678)
at org.springframework.web.cors.DefaultCorsProcessor.checkOrigin(DefaultCorsProcessor.java:193)
And the client does not get a 500. It gets a 401 — even with entirely correct Basic credentials. The next section is why.
The fix is setAllowedOriginPatterns(..), which echoes the request’s own origin back instead of a literal asterisk and is therefore legal with credentials:
allowedHeaders("*") and allowedMethods("*") are unaffected. The prohibition is specific to the origin, because that is the one reflected into a header the browser uses to decide whether the caller may read a credentialed response.
Why your 403 arrives as a 401
This is the mechanism behind more confusing Spring Security bug reports than any other, and both of the surprises in this article route through it.
Something in the chain calls response.sendError(403, ..) — which is what AccessDeniedHandlerImpl does — or lets an exception escape FilterChainProxy.
The container does not write that response. It re-dispatches the request internally to /error, with DispatcherType.ERROR.
Spring Boot registers springSecurityFilterChain for every dispatcher type — SecurityFilterProperties.dispatcherTypes defaults to EnumSet.allOf(DispatcherType.class). The whole chain runs again.
On that second pass, the filters extending OncePerRequestFilter skip themselves: shouldNotFilterErrorDispatch() defaults to true. BasicAuthenticationFilter is one of them. The credential is never re-read.
The filters extending GenericFilterBean do run. AuthorizationFilter is one of them.
So the second pass is authorized but not authenticated. AuthorizationFilter evaluates /error against anyRequest().authenticated(), finds anonymous, denies.
That 401 is what reaches the client. The 403 is gone.
Prove it in one diff. Add a first filter chain that permits /error and change nothing else:
without the /error chain: 401, empty body, WWW-Authenticate: Basic
with the /error chain: 403, {"status":403,"error":"Forbidden","path":"/api/data"}
Permitting /error is not a hole: the error page is rendered from attributes the container set, and an unauthenticated request cannot reach it except through a dispatch the container initiated. The broader alternative is spring.security.filter.dispatcher-types: request, which has wider effects; prefer the /error chain unless you have a specific reason.
This matters more for a SPA than for a server-rendered application. By the time a 403 has become a 401 written on a dispatch where CorsFilter may not have re-run, what the developer sees in the console is neither “403” nor “CSRF”. It is TypeError: Failed to fetch. Every layer has thrown away the cause. The mechanism is set out in full in The Spring Security Filter Chain Explained.
Every SPA tutorial written before Spring Security 6 ends with that line. Three separate things go wrong with it now, and one transcript walks through all three.
One: the bootstrap GET sets no cookie. Since 6.0 the token is deferred. CsrfFilter puts a Supplier<CsrfToken> in a request attribute and materialises it only if something dereferences it. A GET on a JSON endpoint dereferences nothing. The SPA starts up, sees no XSRF-TOKEN cookie, and its first mutating request has nothing to send.
Two: sending the raw cookie value fails. The default handler is XorCsrfTokenRequestAttributeHandler, a BREACH mitigation. It expects the header value to be XOR-masked. CookieCsrfTokenRepository writes the raw token into the cookie. The SPA reads raw, sends raw, the handler tries to unmask it. 403.
Three: the cookie is not sent cross-site anyway. That is the last section.
Spring Security 7.0 added CsrfConfigurer.spa(). Its entire bytecode is two assignments:
1: invokestatic // CookieCsrfTokenRepository.withHttpOnlyFalse()
4: putfield // Field csrfTokenRepository
8: new // class CsrfConfigurer$SpaCsrfTokenRequestHandler
15: putfield // Field requestHandler
SpaCsrfTokenRequestHandler holds two delegates. handle(..) always uses the XOR one; resolveCsrfTokenValue(..) picks the plain one whenever the request carries the header:
A SPA echoing the cookie in X-XSRF-TOKEN compares raw against raw and succeeds; a <form> post keeps the BREACH masking on the hidden field. Both work, from one configuration.
The undocumented part: why the cookie now arrives on the bootstrap GET. The constructor’s third statement is xor.setCsrfRequestAttributeName(null). CsrfTokenRequestAttributeHandler.handle wraps the supplier in a SupplierCsrfToken and sets two request attributes; the key for the second is the configured attribute name, or — when that is null — csrfToken.getParameterName(). Calling getParameterName() on a SupplierCsrfTokendereferences the supplier. The token is generated, the repository saves it, the Set-Cookie goes out.
The eager rendering that fixes problem one is a side effect of needing a string for a map key. It is real and load-bearing: 10-csrf-spa.txt shows the cookie arriving where 08-csrf-naive.txt shows nothing.
Because both assignments are unconditional, spa() is not a “defaults if unset” method:
.csrf(csrf -> csrf.csrfTokenRepository(myRepository).spa()) // myRepository is discarded
.csrf(csrf -> csrf.spa().csrfTokenRepository(myRepository)) // this one wins
A configuration asking for a cookie named MY-CSRF and a header named X-CSRF-TOKEN silently produces XSRF-TOKEN instead. Tracked as spring-security#18718, and the fix is a reordering rather than a different API.
A CSRF rejection does not look like a CSRF rejection.CsrfFilter logs Invalid CSRF token found for http://localhost:8080/api/data at DEBUG and hands off to its AccessDeniedHandler, which calls sendError(403). In a Basic-authenticated API with no /error chain, the client receives 401 with WWW-Authenticate: Basic. Every hour spent re-checking credentials after that is an hour spent on the wrong layer.
And the question worth asking before any of this: if the API authenticates with a bearer token held in memory and never with a cookie, there is no ambient credential for a third-party page to ride on, and csrf.disable() is correct rather than lazy. If any part of the session lives in a cookie — including an HttpOnly refresh cookie — then CSRF protection is load-bearing and spa() is the shortest correct configuration.
SameSite, and the cookie that is never stored
CORS decides whether the browser lets your JavaScript read a response. SameSite decides whether the browser sends the cookie at all. Get CORS perfect and SameSite wrong and the request arrives cleanly, anonymous.
Here is what Spring emits by default under csrf.spa():
The session cookie gets SameSite=Lax from Boot’s default. The CSRF cookie gets no SameSite attribute at all — CookieCsrfTokenRepository‘s default cookie customizer is, in bytecode, a single return. An absent SameSite is not “no restriction”. Chromium-based browsers — Chrome, Edge, Opera, and most of the market — treat it as Lax. Firefox has not enabled Lax-by-default on its release channel; network.cookie.sameSite.laxByDefault is on in Nightly and off elsewhere. So the two disagree, and “it works in Firefox and not in Chrome” is very often this. Write the attribute you mean and the disagreement stops mattering.
Two rules from RFC 6265bis govern the rest.
Storage (§5.5).“If the cookie’s same-site-flag is None and the cookie’s secure-only-flag is false, then abort these steps and ignore the newly created cookie entirely.”SameSite=None without Secure is not a weaker cookie. It is not a cookie. No error is raised and the server has no idea.
Sending (§5.8.3).Strict and Lax cookies are not attached to cross-site requests, except that Lax allows top-level safe-method navigations. A fetch() from a SPA is a subresource request, not a navigation, so Lax does not help it.
Rather than quote those, the companion module implements them — about sixty lines in SpecCookieJar — and runs the application’s real Set-Cookie headers through them. Over a trustworthy origin:
Set-Cookie
Stored?
Sent on a cross-site fetch?
JSESSIONID=s1; HttpOnly; SameSite=Lax
yes
no
JSESSIONID=s2; HttpOnly; SameSite=None
no
—
JSESSIONID=s3; Secure; HttpOnly; SameSite=None
yes
yes
XSRF-TOKEN=t1 (no SameSite)
yes
no
XSRF-TOKEN=t2; SameSite=None
no
—
XSRF-TOKEN=t3; Secure; SameSite=None
yes
yes
The trap that costs a day: plain http in development.Secure is only honoured from a trustworthy origin. Over plain http the attribute is discarded, so SameSite=None; Secure collapses into SameSite=None with no Secure — which is then rejected outright. Nothing survives.
http://localhost is treated as trustworthy by current browsers, so it works. http://127.0.0.1 and http://192.168.x.x are not, and do not. Testing a cross-site SPA against a LAN address means the cookie simply never appears, with no message anywhere.
Boot writes exactly what you tell it, and does not add Secure for you when you ask for none:
server:
servlet:
session:
cookie:
same-site: none
secure: true # required. Omit it and the browser discards the cookie.
http-only: true
Spring Security’s CSRF cookie is separate and needs its own customizer — and the ordering rule from the previous section:
Partitioned (CHIPS) is the next layer: it requires Secure and, in practice, SameSite=None, and it re-keys the cookie to the top-level site that embedded it. That is worth planning for if your API is embedded by third parties.
Should you build any of this?
A same-site deployment deletes this entire article. Serve the SPA and the API from one origin, or from two subdomains of one registrable domain, and: the preflight disappears, SameSite=Lax works, Secure becomes hygiene rather than a prerequisite, and the CSRF cookie arrives without a customizer. A reverse proxy in front of both is usually less work than everything above, and it removes a class of bug rather than configuring around it.
Cross-origin is worth it when the SPA is genuinely a separate product, served from a CDN you do not control, or embedded by third parties. It is not worth it because the frontend and backend are in different repositories.
The edge cases this article skipped
Each of these is one line here and a chapter in the companion module.
PreFlightRequestHandler — a second setter on CorsConfigurer, for applications answering preflights from their own routing. Configuring both it and configurationSource fails at startup with IllegalStateException: Cannot configure both a CorsConfigurationSource and a PreFlightRequestHandler on CorsConfigurer. Chapter 4
Private Network Access — DefaultCorsProcessor handles Access-Control-Request-Private-Network; the switch is CorsConfiguration.setAllowPrivateNetwork(true). Chapter 4
The full DefaultCorsProcessor message set, read out of its constant pool — six strings, each a different diagnosis. Chapter 3
Why DevTools lies to you — the Network tab shows a Set-Cookie whether or not the browser stored it; Application → Cookies is the jar. Chapter 8
Reading the live chain in production, without a diagnostic endpoint: grep the startup log for with filters:. Chapter 8
No Comments yet!