05-spring-security

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.

October 9, 2026
spring-securitycorscsrfsecurity-testingWithMockUserMockMvcspring-boot

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.

java
@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;
}
java
http.cors(Customizer.withDefaults());     // picks up the bean above

Four 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.

java
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.

AuthenticationCSRF needed?
Session cookieYes
JWT in an HttpOnly cookieYes — the browser attaches it
JWT in an Authorization headerNo — script must add it deliberately
HTTP Basic (browser-cached)Yes
Non-browser client onlyNo
🚨

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:

java
@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:

yaml
server:
  servlet:
    session:
      cookie:
        same-site: strict
        http-only: true
        secure: true

Useful, 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

groovy
dependencies {
    testImplementation 'org.springframework.security:spring-security-test'
}

Establishing a principal

java
@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:

java
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 rejection

That 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

java
@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

  1. CORS is a browser read restriction relaxed by the server; CSRF is a server protection against automatic credentials. Different questions.
  2. CORS is not an API access control — it doesn't apply to non-browser callers.
  3. Enumerate origins exactly, and exposedHeaders for anything JavaScript must read.
  4. Configure CORS through Spring Security so CorsFilter is placed before authentication.
  5. allowedOrigins("*") with credentials is forbidden — and the pattern workaround is the same hole.
  6. CSRF protection is needed whenever the browser attaches the credential — cookies, sessions, Basic. Disable only for header-authenticated APIs.
  7. Use SameSite cookies as well, not instead.
  8. 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.