CSRF and SameSite Cookie: Defense Mechanisms in Spring Security
1. Overview
CSRF (Cross-Site Request Forgery) is one of the classic web vulnerabilities but still causes confusion: many people think simply enabling SameSite=Strict is enough, no need for CSRF tokens anymore. This article explains how CSRF attacks work, the real role of SameSite cookies, and how to configure CSRF protection correctly in Spring Security. After reading, you’ll know when you need CSRF tokens, when SameSite is sufficient, and how to write tests to verify it.
2. How CSRF works
Suppose you have a PaymentService with an endpoint to transfer funds, authenticated via session cookie:
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/transfer")
public ResponseEntity<Void> transfer(@RequestParam String toAccount,
@RequestParam BigDecimal amount,
HttpServletRequest request) {
String customerId = (String) request.getSession().getAttribute("customerId");
paymentService.transfer(customerId, toAccount, amount);
return ResponseEntity.ok().build();
}
}
If a customer is already logged in (the browser has a session cookie), a malicious page evil.com just needs to embed this form:
<form action="https://bank.example.com/api/payments/transfer" method="POST">
<input type="hidden" name="toAccount" value="attacker-account">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>
When the customer opens evil.com, the browser automatically attaches the cookie of bank.example.com to the request — because the browser only cares about the destination domain, not where the request originates from. The server receives a request that is valid from a session perspective and executes the fund transfer. That is the essence of CSRF: exploiting the fact that the browser automatically sends credentials (cookies) with requests, regardless of which page initiates the request.
Note: CSRF is only dangerous with “ambient” authentication mechanisms like cookies. If you use an
Authorization: Bearer <token>header and the client must set the header itself (JavaScript cannot do this cross-site due to CORS), classic CSRF does not apply.
3. SameSite cookie attribute
SameSite is a flag on a cookie that tells the browser whether to send this cookie with cross-site requests or not. There are 3 values:
| Value | Behavior |
|---|---|
Strict |
Cookie is sent only when the request is same-site (first-party navigation). Not sent even when the user clicks a link from another site. |
Lax |
Cookie is sent with top-level navigation GET (when user clicks a link), but not sent with POST/subrequests from another site. |
None |
Cookie is always sent cross-site, must be accompanied by Secure. |
Configure in Spring Boot via application.yml:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
With same-site: lax, the CSRF request in the example above (form POST auto-submit from evil.com) will not carry the session cookie — modern browsers block it automatically. This is why Lax became Chrome’s default since 2020 and prevents most form-POST-style CSRF attacks.
3.1. Why SameSite is not enough
SameSite is not a complete solution for a few reasons:
- Subdomain takeover: if your site has
blog.example.comcompromised, it is still considered “same site” withapp.example.com— cookies are still sent. - Older browsers: some browsers/versions do not fully support
SameSite, treating it as if the flag doesn’t exist, falling back to sending cookies as before. - GET requests with side-effects: if your endpoint accidentally allows GET to perform write operations (an anti-pattern but still exists),
Laxstill allows top-level navigation GET to carry cookies.
// Anti-pattern: GET with side-effect, Lax cannot block
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id); // dangerous if exposed via <img src="...">
return ResponseEntity.ok().build();
}
Therefore, SameSite should be seen as an additional defense layer (defense in depth), not a replacement for CSRF tokens.
4. CSRF token: main protection mechanism
CSRF tokens work on the principle of double-submit / synchronizer token: the server generates a random token, attaches it to the session or a separate cookie, and requires all requests that modify data (POST/PUT/DELETE) to include this token in the body or header. An attacker at evil.com cannot read the token (because the same-origin policy blocks reading responses/cookies from other domains), so they cannot forge a valid request.
4.1. Add Spring Security
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
By default, Spring Security 6.x enables CSRF protection for all requests other than GET/HEAD/OPTIONS/TRACE.
4.2. Configure CSRF with cookie-based token repository
For SPAs (React, Vue) calling APIs via AJAX, use CookieCsrfTokenRepository so JavaScript can read the token from the cookie and attach it to the header:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/payments/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
}
Explanation:
withHttpOnlyFalse(): theXSRF-TOKENcookie must be readable by JavaScript (document.cookie), soHttpOnlycannot be set. This is an intentional trade-off — this token is not as sensitive as the session cookie, and losing it alone is not enough to attack if the attacker cannot read it cross-site.CsrfTokenRequestAttributeHandler: handles lazy-loading the token, avoiding the need to render it on the first request (optimized for SPAs).
On the client side, each request needs to read the XSRF-TOKEN cookie and set the X-XSRF-TOKEN header:
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
}
fetch('/api/payments/transfer', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});
An attacker at evil.com cannot read the XSRF-TOKEN cookie of bank.example.com (same-origin policy), so they cannot set the header correctly. Form auto-submit cannot set custom headers, so the request is blocked even if SameSite=None.
4.3. Configure with traditional forms (server-side render)
If using Thymeleaf, the token is rendered directly into the form:
<form th:action="@{/api/orders}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="text" name="productName"/>
<button type="submit">Create Order</button>
</form>
Spring MVC automatically injects _csrf into the model via CsrfRequestDataValueProcessor, no additional code needed.
5. Combining SameSite and CSRF token: defense in depth
These two layers address different issues so should be used together:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
SameSite=Laxon the session cookie blocks most cross-site requests right at the browser level, before the request reaches the server.- CSRF tokens block cases that SameSite doesn’t cover (older browsers, subdomain compromise, or if you intentionally use
SameSite=Nonebecause you need to support cross-site embedding).
6. Writing tests to verify CSRF protection
Use MockMvc with spring-security-test to check that endpoints are blocked when the token is missing:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectPostWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productName\":\"Laptop\"}"))
.andExpect(status().isForbidden());
}
@Test
void shouldAcceptPostWithValidCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productName\":\"Laptop\"}"))
.andExpect(status().isOk());
}
}
Use AssertJ for business logic unrelated to the HTTP layer, for example, verifying that the token is unique per session:
@Test
void csrfTokenShouldBeUniquePerSession() {
CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());
assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
- The first test confirms that a POST request without a token returns
403 Forbidden— the default behavior of Spring Security. with(csrf())is a helper fromspring-security-testthat automatically attaches a valid token to the mock request.- The third test ensures that tokens are not reused or guessable between sessions.
7. Common mistakes and when CSRF tokens are not needed
Mistake 1 — Disabling CSRF globally for APIs “for easier testing”:
// Anti-pattern: don't do this in production
http.csrf(csrf -> csrf.disable());
This is only reasonable if your API is completely stateless — authenticated with JWT in the Authorization header, not using cookies for authentication. In that case, there are no “ambient credentials” for an attacker to exploit.
Mistake 2 — Using SameSite=None without understanding the consequences:
server:
servlet:
session:
cookie:
same-site: none # necessary for cross-site iframes, but reopens CSRF risk
secure: true
If you must use None (for example, embedding your app in a third party’s iframe), you must have CSRF tokens, you cannot skip them.
Mistake 3 — Applying CSRF to GET requests with side-effects:
Don’t design GET requests to delete/update data. Spring Security’s CSRF filter by default does not protect GET (since GET is considered a safe method per RFC 7231), so if you violate REST principles, you create your own vulnerability.
Note: If your backend serves both web apps (cookie-based) and mobile/third-party APIs (token-based) on the same domain, separate CSRF config using
RequestMatcher— enable CSRF only for paths using cookies, disable it for paths using pure Bearer tokens.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/mobile/**") // authenticated with JWT, not cookies
);
return http.build();
}
8. Conclusion
CSRF tokens and SameSite cookies address two different aspects of the same problem: preventing the browser from automatically sending credentials with malicious requests from other sites. SameSite is a defense layer at the browser level, simple to enable but not comprehensive. CSRF tokens are a defense layer at the application level, more reliable but require additional code on both server and client. For systems using session cookies, use both together; for stateless APIs authenticated with Bearer tokens, you can skip the CSRF filter but still keep SameSite to guard against other cookie-related risks.