Hook: Interviewers love this question because CSRF is the classic 'my browser did it for me' bug.
Question: What is CSRF protection in Spring Boot?
Answer: CSRF stands for Cross-Site Request Forgery. It protects you from a malicious site tricking a logged-in user's browser into sending an unwanted state-changing request, like a POST or DELETE, to your app. Spring Security usually blocks those requests unless the browser sends the right CSRF token.
Interview-Ready Answer: In Spring Boot, CSRF protection is Spring Security's way of checking that a state-changing request really came from my application, not from a forged form on another site. It works by creating a random token, storing it in the session or a cookie, and requiring the client to send that token back in a form field or a request header. By default it is enabled for browser-based apps, and for SPAs I usually send the token in a header instead of a hidden field.
CSRF is different from hacking the password. The attacker does not need to know the user's password. The trick is that a browser automatically sends cookies, so if the user is already logged in, a hidden form on another site can ride along with that session cookie. A good mental model is: a session cookie is like a stamped wristband; if the browser brings the wristband, your server may assume the request is legit unless you also require a second proof.
CsrfFilter runs before the controller. A filter is a request interceptor that can accept, modify, or reject the request.403 Forbidden and the controller never runs.By default, Spring Security protects the unsafe HTTP methods and ignores safe ones like GET, HEAD, OPTIONS, and TRACE. That is important because GET should not change server state.
Use CSRF protection whenever the browser automatically sends credentials, especially session cookies or remember-me cookies. That is the normal case for server-rendered Spring MVC apps. You usually keep it on for login forms too, because login is also a state-changing operation.
For stateless APIs that use an Authorization: Bearer ... header and do not rely on cookies, CSRF is often unnecessary. The browser does not automatically invent that header the way it invents cookies. But if you store JWTs in cookies, CSRF risk comes back.
| Option | Best for | Where token lives | Main trade-off |
|---|---|---|---|
| HttpSessionCsrfTokenRepository | Server-rendered forms | Server session | Simple, but needs a session |
| CookieCsrfTokenRepository | SPAs / AJAX | Cookie plus header | Easy for JavaScript, but JS can read the cookie |
In practice, the session-based option is the easiest for Thymeleaf or JSP pages. The cookie-based option is common for single-page apps because JavaScript can copy the token from the cookie into a header such as X-CSRF-TOKEN.
CSRF validation is cheap: roughly O(1) time per protected request, because Spring just looks up one token and compares it. Memory overhead is tiny too, usually one small random token per session, plus the normal session cost. The real cost is not CPU; it is wiring the frontend correctly.
Common edge cases include stale cached forms, missing tokens after login, and endpoints that are intentionally public. Also remember that SameSite cookies help but do not replace CSRF protection. SameSite is a browser cookie rule that limits when cookies travel cross-site, but it is not a full server-side defense and it can behave differently across browsers and flows.
Compared with disabling CSRF, the safer rule is: if the browser auto-sends credentials, require a token; if the client explicitly adds credentials in a header, CSRF risk is much lower. Another useful split is CSRF versus CORS: CORS controls which origins may read responses, while CSRF controls whether a forged request is accepted. They solve different problems.
Imagine a checkout service in an e-commerce app. A signed-in customer can update their shipping address from /checkout/address. If CSRF is disabled, an attacker can place a hidden form on a malicious site that auto-submits to your app as soon as the victim visits. The victim never sees the request, but the browser still sends the session cookie, so the address change succeeds.
The symptoms are sneaky: support tickets say packages were rerouted, audit logs show a normal user session, and the server logs contain unexpected POSTs to address or payment endpoints. If CSRF is enabled but your frontend forgot the token, the symptom is different: users suddenly get 403 Forbidden on form submit, and you may see logs such as invalid or missing CSRF token. In both cases, the browser is involved; the difference is whether the forged request is blocked or allowed.
This is why CSRF is most important on actions that move money, change email or address, or mutate account settings. Read-only pages are not the target; dangerous state changes are.
// src/main/java/com/example/csrfdemo/CsrfProtectionApplication.java
package com.example.csrfdemo;
import java.security.Principal;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.HtmlUtils;
@SpringBootApplication
public class CsrfProtectionApplication {
public static void main(String[] args) {
SpringApplication.run(CsrfProtectionApplication.class, args);
}
}
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Keep CSRF ON: Spring Security will reject unsafe requests without a valid token.
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/error").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("alice")
.password("{noop}password")
.roles("USER")
.build()
);
}
}
@RestController
class ProfileController {
private final AtomicReference<String> nickname = new AtomicReference<>("Alice");
@GetMapping(value = "/profile", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> profilePage(Principal principal, CsrfToken csrfToken) {
// The token is not a password; it is proof that this page came from our app.
String html = """
<!doctype html>
<html>
<body>
<h1>Profile for %s</h1>
<p>Current nickname: <b>%s</b></p>
<form method='post' action='/profile'>
<input type='hidden' name='%s' value='%s'/>
<label>New nickname: <input name='nickname'/></label>
<button type='submit'>Save</button>
</form>
</body>
</html>
""".formatted(
HtmlUtils.htmlEscape(principal.getName()),
HtmlUtils.htmlEscape(nickname.get()),
csrfToken.getParameterName(),
csrfToken.getToken()
);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(html);
}
@PostMapping(value = "/profile", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> updateNickname(@RequestParam(required = false) String nickname) {
// Edge case: CSRF does not validate user input. Blank input is a separate problem.
if (nickname == null || nickname.isBlank()) {
return ResponseEntity.badRequest()
.contentType(MediaType.TEXT_HTML)
.body("<p>Nickname cannot be blank.</p>");
}
this.nickname.set(nickname.trim());
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body("<p>Saved nickname to: " + HtmlUtils.htmlEscape(this.nickname.get()) + "</p>");
}
}
// src/test/java/com/example/csrfdemo/CsrfProtectionApplicationTests.java
package com.example.csrfdemo;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class CsrfProtectionApplicationTests {
@Autowired
MockMvc mvc;
@Test
void postWithoutCsrfTokenIsRejected() throws Exception {
// This is the failure path interviewers care about: the controller is never reached.
mvc.perform(post("/profile").with(user("alice").roles("USER")).param("nickname", "Neo"))
.andExpect(status().isForbidden());
}
@Test
void postWithCsrfTokenIsAccepted() throws Exception {
mvc.perform(post("/profile").with(user("alice").roles("USER")).with(csrf()).param("nickname", "Neo"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Saved nickname to: Neo")));
}
@Test
void blankNicknameStillFailsValidationEvenWithCsrf() throws Exception {
mvc.perform(post("/profile").with(user("alice").roles("USER")).with(csrf()).param("nickname", " "))
.andExpect(status().isBadRequest())
.andExpect(content().string(containsString("Nickname cannot be blank")));
}
@Test
void getProfilePageIncludesAHiddenCsrfField() throws Exception {
mvc.perform(get("/profile").with(user("alice").roles("USER")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("name='_csrf'")));
}
}
Follow-up & Tricky Questions:
CookieCsrfTokenRepository, then JavaScript reads the token cookie and echoes it in a header like X-CSRF-TOKEN. The server checks that header instead of a form field.Common Mistakes:
Memory Hook: Think stamp plus envelope: the session cookie is the stamp that lets the browser enter, and the CSRF token is the sealed envelope that proves the request came from your own form.
Cheat Sheet:
Practice Tasks:
/address and verify that csrf() is required in a MockMvc test.CookieCsrfTokenRepository.withHttpOnlyFalse() and send the token in a custom header.403 Forbidden.