Ch. 8 · Spring Boot

Spring CSRF Protection and Authentication Transport

When Spring Security CSRF protection is needed: cookie sessions vs bearer tokens, SameSite, CookieCsrfTokenRepository and SPA setups in Boot 3.

~6 min readadvancedupdated Oct 6, 2026

“Why did you disable CSRF?” is a question interviewers ask after you show any Spring Security configuration with csrf(csrf -> csrf.disable()). Often the honest answer is “because POST requests returned 403 and that made it go away.” The interviewer wants to know whether you understand what CSRF actually defends against, which comes down to one thing: how the credential travels. If the browser attaches it automatically, you need protection. If your JavaScript must attach it deliberately, you mostly do not.

Before you start

You should understand HTTP cookies, the difference between a session cookie and a bearer token in an Authorization header, and how Spring Security’s SecurityFilterChain is configured with the lambda DSL. Examples use Spring Boot 3.x with Spring Security 6.

The short answer

Cross-site request forgery tricks a logged-in user’s browser into sending a state-changing request to your site from another origin. It works because browsers attach cookies automatically. Spring Security enables CSRF protection by default for unsafe methods (POST, PUT, PATCH, DELETE) and requires a token the attacker’s page cannot read. Keep it on for any cookie-authenticated application, including SPAs using a session cookie. It is reasonable to disable it for a stateless API that authenticates only with a bearer token in the Authorization header, because an attacker cannot make the browser add that header. SameSite cookies reduce the risk but do not replace tokens.

How it works

An attacker hosts this page and lures a logged-in user to it:

<form action="https://bank.example.com/transfers" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="5000">
</form>
<script>document.forms[0].submit()</script>
Text

The browser sends the user’s SESSION cookie with the POST. The server sees an authenticated request. The attacker never needed to read anything.

Spring Security’s CsrfFilter blocks this by requiring a secret token, stored server side (session) or in a cookie, that must be echoed back in a header or form field. The attacker’s page can make the browser send cookies, but the same-origin policy prevents it from reading the token, so it cannot put the token in the request.

Since Spring Security 6, two details changed. The token is deferred, loaded only when something needs it, and it is masked with random bytes on each render (BREACH protection) by XorCsrfTokenRequestAttributeHandler. Server-rendered Thymeleaf forms handle both automatically through the hidden _csrf field. SPAs need deliberate configuration.

Step-by-step walkthrough

Step 1: Decide based on credential transport

Ask how the browser authenticates. Session cookie or “remember me” cookie: CSRF protection required. A JWT stored in a cookie: still a cookie, still required. A token held in memory by your JavaScript and sent as Authorization: Bearer ...: not exposed to classic CSRF.

Step 2: Configure a stateless bearer-token API

For a resource server that never creates sessions, disabling CSRF is legitimate, and you should say why in code:

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        // Credentials arrive only in the Authorization header, which browsers never add on their own.
        .csrf(csrf -> csrf.disable());
    return http.build();
}
java

A React or Angular app served from the same site that logs in with a session cookie needs CSRF. Store the token in a readable cookie so JavaScript can copy it into a header:

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .formLogin(Customizer.withDefaults())
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())   // XSRF-TOKEN cookie
            .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));    // accept the raw cookie value
    return http.build();
}
java

The client reads XSRF-TOKEN and sends it as X-XSRF-TOKEN. Angular’s HttpClient does this automatically for same-origin requests; Axios has xsrfCookieName and xsrfHeaderName options. The plain CsrfTokenRequestAttributeHandler is used because the SPA sends the unmasked cookie value; the Spring Security reference also shows a hybrid handler if you need BREACH protection for server-rendered pages in the same app.

Because tokens are deferred, the XSRF-TOKEN cookie appears only after something touches the token. If the SPA’s first request is a POST, it fails with 403. A small filter or an endpoint that loads the token fixes it:

@GetMapping("/api/csrf")
CsrfToken csrf(CsrfToken token) {
    return token;   // resolving the argument loads the token and writes the cookie
}
java

Step 5: Add SameSite as defense in depth

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

Lax stops cookies on cross-site POSTs in modern browsers, but older browsers, same-site subdomain attacks and top-level GET side effects remain. Keep the token.

Worked scenario

A team migrates an internal admin portal to Spring Boot 3. After the upgrade, every form submit from their single-page frontend returns 403. Someone adds .csrf(csrf -> csrf.disable()), the portal works, and the change ships.

The portal authenticates with a JSESSIONID cookie. A month later, a security review demonstrates a page on another domain that auto-submits a POST to /admin/users/42/roles while an admin is logged in, granting itself admin rights.

The original 403 had two real causes: the SPA was sending the masked token format it no longer received correctly, and the deferred token meant no cookie existed before the first POST. The fix restores protection: re-enable CSRF with CookieCsrfTokenRepository.withHttpOnlyFalse() and CsrfTokenRequestAttributeHandler, add the /api/csrf bootstrap call on app start, and configure the HTTP client to send X-XSRF-TOKEN. A test now asserts that a POST without a token is rejected.

Common mistake

  • “We use JWT, so CSRF does not apply.” Only if the JWT travels in a header your code sets. A JWT in a cookie is a cookie.
  • “CORS protects us.” CORS controls whether a script can read a cross-origin response. A simple form POST is sent without a preflight, and the side effect happens anyway. See CORS configuration.
  • “SameSite=Lax is enough.” It is good defense in depth, not a complete control.
  • Making GET requests change state. CSRF protection skips safe methods by design; a GET /delete?id=5 is unprotected.
  • Disabling CSRF globally when only one webhook endpoint needs it. Use csrf.ignoringRequestMatchers("/webhooks/**") and authenticate the webhook with a signature.

Verify the behavior

Spring Security’s test support makes both sides easy to assert:

@WebMvcTest(AdminController.class)
@Import(WebSecurityConfig.class)
class CsrfTest {

    @Autowired MockMvc mvc;

    @Test
    @WithMockUser(roles = "ADMIN")
    void postWithoutTokenIsForbidden() throws Exception {
        mvc.perform(post("/admin/users/42/roles").content("ADMIN"))
           .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void postWithTokenIsAccepted() throws Exception {
        mvc.perform(post("/admin/users/42/roles").content("ADMIN").with(csrf()))
           .andExpect(status().isOk());
    }
}
java

In a browser, open developer tools and confirm the XSRF-TOKEN cookie exists and the X-XSRF-TOKEN header is on each mutating request.

Follow-up questions

Why are safe methods exempt? GET, HEAD, OPTIONS and TRACE should not change state, so forging them gains nothing. That only holds if your application respects HTTP semantics.

Why must the cookie be readable (HttpOnly false)? The double-submit pattern relies on JavaScript reading the cookie and copying it into a header. The session cookie itself stays HttpOnly.

What does BREACH have to do with CSRF tokens? BREACH extracts secrets from compressed HTTPS responses. Masking the token differently on every response defeats it, which is why Spring Security 6 masks by default.

How do you handle logout? Logout should be a POST with a token; a GET logout lets any page log your users out.

Interview exercise

Your company has a server-rendered Thymeleaf site with session login, plus a mobile-app API under /api/** that uses OAuth2 bearer tokens. Describe the security configuration for CSRF.

Answer and reasoning

Use two SecurityFilterChain beans. The /api/** chain, ordered first with securityMatcher("/api/**"), is a stateless resource server with CSRF disabled, because mobile clients send tokens in the Authorization header and nothing is automatic. The default chain keeps CSRF enabled with the default session-backed repository; Thymeleaf injects the masked _csrf hidden field into every form, so no extra code is needed. Session cookies get Secure, HttpOnly and SameSite=Lax. The reasoning: CSRF exposure is decided per credential transport, so the decision belongs per chain, not globally. Ordering is covered in security filter chain.

Continue learning

More in Spring Boot

read ✓Spring Boot · hard

Spring Actuator Exposure and Operational Access

Which Spring Boot Actuator endpoints to expose, how exposure differs from authorization, and how to lock down env, heapdump and health details.

~6 min readread →
esc