Have you ever configured Spring Security only to find your POST requests suddenly returning 403 errors? Many developers end up writing csrf().disable() because they can’t find the cause in the logs.
But whether that’s the right choice depends entirely on the type of application you’re building. For REST APIs it’s fine to disable, but for Thymeleaf form-based apps, keeping it enabled is a genuine security requirement. This article will help you understand the reasoning behind each configuration.
How CSRF Attacks Work
CSRF stands for Cross-Site Request Forgery — an attack that tricks an authenticated user into performing unintended actions.
A concrete scenario makes this easier to understand. Suppose you’re logged into a banking site (bank.example.com) and you open a malicious site (evil.example.com) in another tab. 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 you open the page, the form is automatically submitted. At that point, the browser automatically attaches the session cookie for bank.example.com. From the bank server’s perspective, it looks exactly like a legitimate user initiating a transfer. The root cause of this attack is a browser specification: cookies are automatically sent for the target domain of any request.
Why CSRF Is Unnecessary for REST APIs (JWT Authentication)
For stateless REST APIs using JWT, disabling CSRF is safe. There are three reasons.
JWTs are sent via the Authorization header. Since browsers don’t automatically attach custom headers, a form on a malicious site has no way to send the token.
Custom headers are blocked by CORS preflight. Adding an Authorization header causes the browser to first send an OPTIONS request. If CORS is configured correctly, requests from other domains are stopped at this point.
Stateless APIs don’t maintain sessions. CSRF relies on session cookies being sent automatically — without a session, the conditions for the attack simply don’t exist.
JWT implementation is covered in detail in this article.
Why CSRF Is Required for Session + Cookie Form Applications
Server-side rendering apps like Thymeleaf typically manage session IDs via cookies. This creates exactly the conditions CSRF attacks require.
Once a session cookie is issued after login, the browser automatically attaches it to requests to the same domain. Even if a form on a malicious site submits a request, the browser will include the session cookie — making it impossible for the server to distinguish from a legitimate request.
CSRF tokens solve this problem. The server embeds a random token in the form and rejects any request where the token doesn’t match, effectively blocking requests originating from malicious sites.
Diagnosing 403 Errors
Start by checking your application logs. If you see InvalidCsrfTokenException or MissingCsrfTokenException, CSRF is the cause.
The classic CSRF symptom is GET requests succeeding while POST/PUT/DELETE requests return 403. To get more detailed logs, enable TRACE-level logging:
# application.properties
logging.level.org.springframework.security=TRACE
It’s easy to confuse this with CORS errors, but CORS is a mechanism where the browser blocks requests to a different origin — it’s a separate issue from CSRF. CORS configuration is explained in this article.
Spring Security 6: Disabling CSRF for REST APIs
For JWT-authenticated REST APIs, configure it as follows:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// JWT auth: no session, no CSRF needed
.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, this was written as http.csrf().disable(), but version 6 standardizes on the Lambda DSL. Leaving a comment explaining why CSRF is disabled helps anyone reading the config later understand the intent. Pairing this with SessionCreationPolicy.STATELESS makes the no-session intent explicit in the code itself.
Spring Security 6: Enabling CSRF for Thymeleaf Form Applications
Spring Security enables CSRF by default. Use withDefaults() to make that intent explicit in the code:
import static org.springframework.security.config.Customizer.withDefaults;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(withDefaults()) // Enabled for session auth (explicit default behavior)
.formLogin(withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
In Thymeleaf, using th:action automatically embeds a hidden _csrf field in the form:
<!-- Thymeleaf template -->
<form th:action="@{/profile/update}" method="post">
<input type="text" name="username" />
<button type="submit">更新</button>
</form>
Checking the rendered HTML in your browser’s developer tools will show the hidden field has been inserted:
<!-- Actual rendered HTML -->
<form action="/profile/update" method="post">
<input type="hidden" name="_csrf" value="abc123...">
<input type="text" name="username" />
<button type="submit">更新</button>
</form>
Plain HTML forms that don’t use Thymeleaf don’t get this automatic injection, so manual configuration is required. For more on using Thymeleaf, see this article.
Sending CSRF Tokens with AJAX Requests
When using AJAX in a form-based app, you need to attach the CSRF token as a header. The standard approach is to embed the token in meta tags and retrieve it via 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 read the token from a cookie, use CookieCsrfTokenRepository:
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
Using withHttpOnlyFalse() allows JavaScript to read the cookie. The token is issued under the name XSRF-TOKEN and must be sent as the X-XSRF-TOKEN header in requests for authentication.
SameSite Cookies and CSRF Mitigation
SameSite=Strict prevents cookies from being sent on cross-site requests, which protects against CSRF attacks. SameSite=Lax permits GET navigation requests but does not attach cookies on cross-site POST submissions.
Modern browsers like Chrome now apply Lax by default, which has reduced the risk of CSRF attacks compared to before. However, since it doesn’t work in older browsers or some environments, treat SameSite as a complement to CSRF tokens, not a replacement. The correct approach is to use it alongside Spring Security’s token validation.
Summary
The right configuration depends on your application type:
| 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 + form | Configure per-path using requestMatcher |
Writing csrf().disable() in a session-authenticated form app creates a real security hole. When you hit a 403 error, start by identifying the cause in the logs, then choose the configuration that fits your application.
Also see: Basic authentication configuration and OAuth2 social login with Google.