Have you ever set up Spring Security only to suddenly start getting 403 errors on POST requests? Many developers, unable to find the cause in the logs, end up just writing csrf().disable() and moving on.
But whether this configuration is correct depends entirely on the type of application you’re building. For REST APIs, disabling it is fine, but for Thymeleaf form applications, leaving it disabled creates a genuine security risk. In this article, let’s organize the rationale behind these settings.
How CSRF Attacks Work
CSRF stands for Cross-Site Request Forgery, an attack that causes a logged-in user to perform unintended operations.
It’s easier to understand with a concrete scenario. Suppose you’re logged into a banking site (bank.example.com), and you open a malicious site (evil.example.com). That site contains the following hidden HTML:
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="100000">
</form>
<script>document.forms[0].submit();</script>
The moment the page opens, the form is automatically submitted. At this point, the browser automatically attaches the session cookie for bank.example.com. From the bank server’s perspective, it looks as if a legitimate user requested the transfer. The root cause that makes this attack possible is the browser specification of “automatically attaching cookies for the destination domain to requests.”
Why CSRF Is Unnecessary for REST APIs (JWT Authentication)
For stateless REST APIs using JWT, disabling CSRF is not a problem. There are three reasons.
JWTs are sent in the Authorization header. Since browsers do not automatically attach custom headers, forms on malicious sites cannot send the token.
Custom headers are blocked by CORS preflight. When you attach the Authorization header, the browser sends an OPTIONS request in advance. If CORS is properly configured, cross-domain requests are stopped here.
Stateless APIs do not have sessions. CSRF relies on session cookies being automatically sent, so without a session, the conditions for the attack do not hold.
For JWT implementation details, see this article.
Session + Cookie Authentication Form Apps Need CSRF
Server-side rendering applications like Thymeleaf typically manage session IDs via cookies. This is exactly the situation in which the conditions for CSRF attacks are met.
When a session cookie is issued after login, the browser automatically attaches that cookie to requests to the same domain. Even if a malicious site sends a request via a <form> tag, the browser will attach the session cookie, and the server cannot distinguish it from a legitimate request.
CSRF tokens solve this problem. The server embeds a random token in the form and rejects requests where the token does not match, repelling requests from malicious sites.
Diagnosing 403 Errors
Quick Reference for Symptoms, Causes, and Remedies
| Symptom | Likely Cause | Remedy |
|---|---|---|
| GET works but POST/PUT/DELETE returns 403 | CSRF token not attached | Embed _csrf in the form, or disable CSRF if using JWT authentication |
InvalidCsrfTokenException in logs | Old token cached | Clear browser cookies and session, then log in again |
MissingCsrfTokenException in logs | Token not included in request | For AJAX, attach the X-CSRF-TOKEN header |
| 403 even with same origin | Spring Security filter order or path exclusion settings | Check the matching order in authorizeHttpRequests |
| Preflight OPTIONS fails | Insufficient CORS configuration (not CSRF) | Check CorsConfigurationSource |
If the cause cannot be identified with the above, enable logging.level.org.springframework.security=TRACE and check the logs around CsrfFilter.
First, check the application logs. If you see InvalidCsrfTokenException or MissingCsrfTokenException, CSRF is the cause.
GET works but only POST/PUT/DELETE returns 403 is the classic symptom of a CSRF issue. To see more detailed logs, set the TRACE level.
# application.properties
logging.level.org.springframework.security=TRACE
This is easily confused with CORS errors, but CORS is a browser mechanism that blocks requests to a different origin — it’s a separate issue from CSRF. CORS configuration is explained here.
Spring Security 6: Disabling CSRF for REST APIs
For a JWT-authenticated REST API, configure it as follows.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// No session and no CSRF needed for JWT authentication
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
In Spring Security 5 and earlier, you would write http.csrf().disable(), but in 6 this has been unified into the Lambda DSL. Leaving a comment about “why it is disabled” makes it easier for someone reading the configuration later to understand the intent. Combined with SessionCreationPolicy.STATELESS, the intent of not creating a session is also expressed in code.
Spring Security 6: Enabling CSRF for Thymeleaf Form Apps
Spring Security enables CSRF by default. Use withDefaults() to make the intent explicit in code.
import static org.springframework.security.config.Customizer.withDefaults;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(withDefaults()) // Enabled for session authentication (explicit default)
.formLogin(withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
In Thymeleaf, when you use th:action, the _csrf hidden field is automatically embedded.
<!-- Thymeleaf template -->
<form th:action="@{/profile/update}" method="post">
<input type="text" name="username" />
<button type="submit">Update</button>
</form>
If you check the generated HTML in your browser’s developer tools, you can see that the hidden field has been inserted.
<!-- Actual output HTML -->
<form action="/profile/update" method="post">
<input type="hidden" name="_csrf" value="abc123...">
<input type="text" name="username" />
<button type="submit">Update</button>
</form>
For plain HTML forms that don’t use Thymeleaf, automatic embedding does not occur, so manual configuration is needed. See here for detailed Thymeleaf usage.
Sending CSRF Tokens in AJAX Requests
Behavior Details of CookieCsrfTokenRepository.withHttpOnlyFalse()
CookieCsrfTokenRepository.withHttpOnlyFalse() attaches the token to the response as a cookie named XSRF-TOKEN, issued without the HttpOnly attribute. This allows JavaScript (axios / fetch / Angular’s HttpClient, etc.) to read the token via document.cookie.
The correspondence between the issued cookie and the request header is as follows.
| Item | Value |
|---|---|
| Cookie name | XSRF-TOKEN |
| Cookie attributes | HttpOnly=false, Path=/, SameSite=Lax (default) |
| Expected request header | X-XSRF-TOKEN: <token value> |
| Server-side validation | CsrfFilter compares the header value with the cookie value |
Angular’s HttpClientXsrfModule automatically forwards the XSRF-TOKEN cookie to the X-XSRF-TOKEN header, so it fits this mechanism by default. For axios, you can achieve the same behavior with withCredentials: true, xsrfCookieName: 'XSRF-TOKEN', and xsrfHeaderName: 'X-XSRF-TOKEN'.
As a caveat, if you choose withHttpOnlyFalse(), you need to strengthen your XSS countermeasures. If XSS succeeds, the token can be read via JavaScript, so be thorough with Content-Security-Policy header settings and input escaping.
When using AJAX in a form app, you need to attach the CSRF token to the header. The standard approach is to embed the token in a meta tag and retrieve it with JavaScript.
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
const token = document.querySelector('meta[name="_csrf"]').content;
const header = document.querySelector('meta[name="_csrf_header"]').content;
fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[header]: token
},
body: JSON.stringify({ key: 'value' })
});
If you want fetch or axios to automatically reference the cookie, use CookieCsrfTokenRepository.
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
Setting withHttpOnlyFalse() allows JavaScript to read the cookie. The token is issued under the name XSRF-TOKEN, and when making requests it is sent as the X-XSRF-TOKEN header for authentication.
SameSite Cookies and Their Relationship to CSRF Mitigation
The SameSite attribute is a browser specification that controls whether cookies are attached to cross-site requests, contributing to CSRF attack mitigation. Let’s organize the behavior of the three values.
| Value | Same-site navigation | Cross-site GET (link click, etc.) | Cross-site POST (attack scenario) | CSRF mitigation effect |
|---|---|---|---|---|
Strict | Sent | Not sent | Not sent | Strongest (login state may not even be maintained via external links) |
Lax | Sent | Sent | Not sent | Practical balance (default in modern browsers) |
None | Sent | Sent | Sent | No mitigation effect (Secure required) |
To explicitly set SameSite in Spring Boot, specify it in application.properties as follows.
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
The actual Set-Cookie response header takes the following form.
Set-Cookie: JSESSIONID=ABC123; Path=/; Secure; HttpOnly; SameSite=Lax
Recent versions of Chrome 80+, Firefox, and Safari treat unspecified SameSite as equivalent to Lax, making CSRF attacks more difficult than before. However, SameSite alone provides insufficient defense in the following cases.
- When you need to allow access from older browsers (Chrome 79 or earlier, IE11, etc.)
- Attacks between subdomains (
SameSitetreats same-site as eTLD+1, so it may not prevent attacks fromevil.example.comtobank.example.com) - With
Lax, POST requests can be blocked, but GET requests with side effects (which should be avoided by design) cannot
Therefore, SameSite should be positioned not as a replacement for CSRF tokens, but as one layer in defense in depth, and the correct approach is to operate it in combination with Spring Security’s token validation.
Summary
The configuration changes depending on the type of application.
| Case | Configuration |
|---|---|
| REST API + JWT/API key | csrf(csrf -> csrf.disable()) + STATELESS |
| Thymeleaf form + session | csrf(withDefaults()) + automatic token embedding |
| Form app + AJAX | CookieCsrfTokenRepository.withHttpOnlyFalse() |
| Mixed REST API + forms | Branch configuration by path using requestMatcher |
If you write csrf().disable() in a session-authenticated form app, it becomes a real security hole. When you encounter a 403 error, first identify the cause from the logs, then choose the configuration that fits your application.
As related security operational tips, if you’re handling sensitive information in configuration files, see How to Encrypt Sensitive Information in Configuration Files Using Jasypt in Spring Boot, and if you want to handle exceptions other than 403 at production-quality level, also see Implementing Spring Boot’s GlobalExceptionHandler for Production Use.