CORS, CSRF and Testing Security
Two protections that answer different questions, why allowedOrigins('*') with credentials is rejected, and the tests that catch a misordered rule chain.
Two Protections, Two Questions
CORS and CSRF get confused constantly, usually because both appear in the same configuration block and both get "fixed" by disabling them.
CORS asks: may JavaScript on origin X read a response from my server? It is a browser restriction, relaxed by the server.
CSRF asks: was this state-changing request deliberately made by my user, or triggered by another site using their credentials? It is a server-side protection against a browser's willingness to attach credentials automatically.
They're close to opposites in intent. CORS relaxes a default restriction; CSRF adds a restriction to a default permissiveness. Disabling one does not affect the other.
CORS is not a security control for your API. It restricts what browser JavaScript may read — it does nothing to curl, a script, a mobile app or a server-side caller, none of which enforce it. A permissive CORS policy doesn't by itself expose your data; your authentication and authorisation do that job. Conversely, a restrictive CORS policy protects nothing if an endpoint is unauthenticated.
CORS
The browser blocks cross-origin reads unless the server opts in with response headers. For anything beyond a simple GET, the browser first sends a preflight OPTIONS request asking what's allowed.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://shop.example.com", "https://admin.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
config.setExposedHeaders(List.of("Location", "X-Total-Count"));
config.setAllowCredentials(true);
config.setMaxAge(Duration.ofHours(1));
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}http.cors(Customizer.withDefaults()); // picks up the bean aboveFour details account for most CORS problems.
allowedOrigins must include the scheme and port and must match exactly. https://shop.example.com does not cover http://shop.example.com, https://www.shop.example.com, or https://shop.example.com:8443. For patterns, use setAllowedOriginPatterns.
exposedHeaders is needed to read a response header. By default JavaScript sees only a handful of simple headers — so the Location header from a 201, which the REST guide recommends returning, is invisible to a browser client unless you expose it. Same for a pagination count.
maxAge caches the preflight. Without it, every non-simple request is two round trips. An hour is reasonable.
CORS must be configured through Spring Security, not only via WebMvcConfigurer. The security filter chain runs before DispatcherServlet, so MVC-level CORS config never gets a chance on a rejected request. http.cors(...) is what places CorsFilter correctly — and recall from the filter chain guide that it sits early, before authentication, so credential-free preflights succeed.
allowedOrigins("*") with allowCredentials(true) is rejected by the specification, and Spring throws at startup rather than letting you ship it. The combination would let any website make authenticated requests as your logged-in user and read the responses — a complete bypass of same-origin protection.
The workaround people reach for, setAllowedOriginPatterns(List.of("*")) with credentials, is the same vulnerability with the guardrail removed. Spring permits it because patterns have legitimate narrow uses (https://*.example.com). Enumerate your real origins.
CSRF
The attack: your user is logged into your site; they visit a malicious page; it submits a form to your server. The browser attaches their session cookie automatically, and your server sees an authenticated request to transfer money.
The defence is a token the attacker's page cannot obtain: a random value Spring puts in the session and requires on every state-changing request. Cross-origin JavaScript can't read it, so it can't be forged.
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));CSRF protection applies to POST, PUT, PATCH and DELETE — not GET, HEAD, OPTIONS or TRACE, which are assumed safe. (Which is itself an argument for not making a GET endpoint change state: it would be unprotected.)
When to disable it, precisely
The decision rule is narrow and worth stating exactly:
CSRF protection is needed when the browser automatically attaches a credential. It is unnecessary when the client must explicitly supply one.
| Authentication | CSRF needed? |
|---|---|
| Session cookie | Yes |
JWT in an HttpOnly cookie | Yes — the browser attaches it |
JWT in an Authorization header | No — script must add it deliberately |
| HTTP Basic (browser-cached) | Yes |
| Non-browser client only | No |
csrf.disable() is the most-copied line in Spring Security tutorials and is correct only for the header-authenticated case. Copied into a session-based or cookie-based application, it removes a real protection against a real and easy attack. The giveaway is .formLogin() or a cookie-stored token in the same configuration — if either is present, CSRF protection belongs on.
This is also where the multiple filter chains pattern earns its place — stateless API chain with CSRF disabled, session-based web chain with it enabled, each correct for its own model instead of one global compromise:
@Bean @Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/api/**")
.csrf(CsrfConfigurer::disable) // header-authenticated
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
@Bean @Order(2)
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
return http
.csrf(Customizer.withDefaults()) // cookie-authenticated — keep it
.formLogin(Customizer.withDefaults())
.build();
}SameSite cookies
Modern browsers default cookies to SameSite=Lax, which blocks them on cross-site POSTs — meaningful defence in depth:
server:
servlet:
session:
cookie:
same-site: strict
http-only: true
secure: trueUseful, and not a replacement for CSRF tokens: older browsers, some cross-subdomain arrangements, and Lax's allowance for top-level GET navigation all leave gaps. Use both.
Testing Security
dependencies {
testImplementation 'org.springframework.security:spring-security-test'
}Establishing a principal
@Test
@WithMockUser(username = "alice", roles = "USER")
void authenticatedUserCanListOwnOrders() throws Exception {
mockMvc.perform(get("/api/orders")).andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "orders:refund")
void refundRequiresThePermission() throws Exception {
mockMvc.perform(post("/api/orders/42/refund").with(csrf()))
.andExpect(status().isOk());
}
@Test
@WithUserDetails("alice@example.com") // loads via your UserDetailsService
void loadsRealUserAuthorities() throws Exception { }
@Test
void jwtAuthenticatedRequest() throws Exception {
mockMvc.perform(get("/api/orders")
.with(jwt().jwt(j -> j.claim("scope", "orders:read"))))
.andExpect(status().isOk());
}Note roles = "USER" yields the authority ROLE_USER while authorities = "orders:refund" adds no prefix — the same distinction as the authorisation guide, and a test asserting the wrong one passes for the wrong reason.
@WithUserDetails is the more faithful option when your authority mapping has logic in it, since it exercises your real UserDetailsService.
CSRF in tests
With CSRF enabled, a POST without a token is 403 — which catches people out when a test fails for a reason unrelated to what it's testing:
mockMvc.perform(post("/orders").with(csrf())) // valid token
mockMvc.perform(post("/orders").with(csrf().asHeader())) // as a header
mockMvc.perform(post("/orders").with(csrf().useInvalidToken())) // assert rejectionThat third form is a test worth having: it verifies CSRF protection is actually active, rather than assuming it from configuration.
The tests that matter most
@Test
void anonymousCannotReachProtectedEndpoint() throws Exception {
mockMvc.perform(get("/api/orders")).andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void regularUserCannotReachAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/accounts")).andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "bob")
void userCannotReadAnotherUsersOrder() throws Exception {
mockMvc.perform(get("/api/orders/42")) // alice's order
.andExpect(status().isForbidden());
}Negative tests are the ones that find bugs. A happy-path test passes whether your rules are correct or your chain ends in permitAll(). Asserting 401 for anonymous and 403 for under-privileged is what catches a misordered authorizeHttpRequests — the failure mode from the filter chain guide, where a broad rule shadows a specific one and quietly grants access.
The third test is the most valuable and least written: object-level authorisation. An endpoint correctly requiring authentication while happily returning any user's data by ID is one of the most common real-world API vulnerabilities, and only a test with two users catches it.
Add one test asserting that a representative new endpoint requires authentication by default. It encodes the deny-by-default rule as something the suite enforces, so a future permitAll() broadening the catch-all fails a test rather than silently opening everything added since.
Check yourself
A SPA on https://shop.example.com calls an API on https://api.example.com. GETs work; POSTs fail with a CORS error mentioning the preflight. The config sets allowedOrigins and allowedMethods including POST. What is the most likely cause?
The Mental Model, Restated
- CORS is a browser read restriction relaxed by the server; CSRF is a server protection against automatic credentials. Different questions.
- CORS is not an API access control — it doesn't apply to non-browser callers.
- Enumerate origins exactly, and
exposedHeadersfor anything JavaScript must read. - Configure CORS through Spring Security so
CorsFilteris placed before authentication. allowedOrigins("*")with credentials is forbidden — and the pattern workaround is the same hole.- CSRF protection is needed whenever the browser attaches the credential — cookies, sessions, Basic. Disable only for header-authenticated APIs.
- Use
SameSitecookies as well, not instead. - Write the negative tests — 401 anonymous, 403 under-privileged, and one user reading another's data.
Phase 5 in Four Sentences
Spring Security is one servlet filter delegating to an ordered chain that runs before DispatcherServlet, which is why rule order matters and why controller advice never formats a 401. Authentication picks a mechanism — sessions for server-rendered apps, bearer tokens for APIs — and a hand-rolled JWT filter owes you five validation checks that the resource server support already performs. Authorisation works at two layers, and the proxy-based layer shares the self-invocation hole that makes it a privilege-escalation risk rather than a mere bug. OAuth2 delegates all of this to a provider, with the standing rule that an access token authorises and an ID token identifies and the two are never interchangeable.
What's Next
Phase 6 takes a working, secured application and makes it operable: building a container image properly, graceful shutdown with liveness and readiness probes, GraalVM native images and what they cost, virtual threads in Spring Boot 4, the full observability stack, and a production hardening pass over everything.