Spring Boot
Spring Security CSRF Protection: Tokens, Thymeleaf, SPAs, REST APIs and Common 403 Errors
CSRF (Cross-Site Request Forgery) is an attack where a malicious site tricks a user's browser into making an authenticated request to your application. Spring Security enables CSRF protection by default for all state-changing requests. This guide explains when CSRF protection is needed, how the default token flow works, and how to integrate it with Thymeleaf forms, single-page apps and REST APIs. It also covers testing, custom 403 responses, SameSite cookies, and the mistakes that most often cause unexplained 403 errors. Examples target Spring Security 6.x (Spring Boot 3.x).
What CSRF Is and When It Matters
A CSRF attack exploits the fact that browsers automatically attach certain credentials to every request sent to a domain, no matter which page triggered the request. The classic case is the session cookie. An attacker can put a form or a script on their own site that sends a request to your application, and the browser silently adds the victim's session cookie, so the request looks like it came from the logged-in user.
So the real question is not "is my app stateless?" but "does the browser send my credentials automatically?" CSRF protection is needed when authentication relies on something the browser attaches by itself:
Session cookies such as JSESSIONID, and remember-me cookies. A JWT stored in a cookie counts here too, because the cookie is attached automatically.
HTTP Basic authentication in the browser. Once a user has entered Basic credentials, browsers resend them automatically for that site, so Basic auth is also exposed.
Client certificates (mutual TLS) in the browser.
CSRF protection is not needed when authentication is a credential that your own JavaScript must add to each request, typically Authorization: Bearer <token> held in memory, and when the API is used by non-browser clients such as mobile apps or other servers. A malicious site cannot make the victim's browser add that header, and cross-origin scripts cannot read it.
Spring Security enables CSRF protection by default, and that is the right default for anything with browser sessions.
The Attack in Practice
Here is the attack step by step. The user logs in to bank.com and the browser stores a session cookie. While still logged in, the user visits evil.com. That page contains a hidden form that points to bank.com and submits itself with JavaScript. The browser sends the POST to bank.com together with the session cookie, and bank.com sees an authenticated request and performs the transfer. The attacker never needs to read the response or know the cookie value.
The defense is the synchronizer token pattern. The server generates an unpredictable token, ties it to the user's session and includes it in pages it renders. On every state-changing request, the server checks that the request carries the matching token. The attacker's page cannot read the token because the same-origin policy blocks cross-site reads, so the forged request arrives without a valid token and is rejected.
html
<!-- Hosted on evil.com: the browser will attach bank.com cookies when this submits -->
<form id="attack" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="amount" value="10000">
<input type="hidden" name="to" value="attacker">
</form>
<script>
document.getElementById('attack').submit(); // runs as soon as the victim opens the page
</script>Defense in Depth: SameSite Cookies
Modern browsers treat cookies without an explicit SameSite attribute as SameSite=Lax, which already stops most cross-site form POST attacks. Setting the attribute explicitly makes the behavior consistent and visible. This is an extra layer, not a replacement for CSRF tokens: SameSite does not protect against attacks from other subdomains of the same site, and older browsers behave differently.
Spring Boot lets you set the attribute on the session cookie with a property. Use lax for normal applications. Use strict only if you can accept that users arriving from external links will have to log in again, and use none only for genuinely cross-site embedding, which requires the Secure flag and brings the CSRF risk back.
yaml
# application.yml
server:
servlet:
session:
cookie:
same-site: lax # explicit SameSite for the session cookie
secure: true # send only over HTTPS
http-only: true # not readable by JavaScriptDefault CSRF Configuration
With Spring Security on the classpath you do not have to configure anything to get CSRF protection. The CsrfFilter checks every request that is not a safe method. Safe methods (GET, HEAD, OPTIONS and TRACE) are not checked, which is why GET requests must never change state.
By default the token is stored in the HttpSession. In Spring Security 6 it is loaded lazily, so it is only generated when something actually reads it, and it is masked on each response as protection against BREACH-style attacks. A request that needs a token can send it in one of two places: the _csrf request parameter (form posts) or the X-CSRF-TOKEN header (AJAX and JSON requests).
Because the default token lives in the session, it requires a session. If you set the session policy to STATELESS and keep the default repository, there is no place to store the token and every protected request fails with 403. Either disable CSRF for that chain (only valid for header-based authentication) or switch to a cookie-based token repository.
The configuration below is the explicit form of the default. You never need to write it unless you are making a deliberate change.
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults()) // same as not configuring CSRF at all
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
// Handy for JSON clients and tests: an endpoint that hands out the current token.
// Spring resolves the CsrfToken method argument from the request.
@RestController
public class CsrfController {
@GetMapping("/csrf")
public CsrfToken csrf(CsrfToken token) {
return token; // JSON: parameterName, headerName, token
}
}Calling a Protected Endpoint: What a Valid Request Looks Like
A state-changing request from a logged-in session must carry the token. Without it the CsrfFilter rejects the request with HTTP 403 before it reaches your controller. The response body depends on your error handling: Spring Boot's default error handling usually produces a standard error JSON or page, and the server log contains the reason (for example "Invalid CSRF token found"). If you want a specific JSON body, see the section on custom 403 responses.
The commands below show the flow with curl for a session-based application. The cookie jar keeps the session cookie between calls, and the token is sent in the X-CSRF-TOKEN header. The session must already be authenticated (for example by logging in through the form first).
Shell
# 1. Fetch the current token (also sets the session cookie):
curl -s -c jar.txt -b jar.txt http://localhost:8080/csrf
# {"parameterName":"_csrf","headerName":"X-CSRF-TOKEN","token":"k3J..."}
# 2. POST without the token: rejected
curl -i -b jar.txt -X POST http://localhost:8080/users \
-H "Content-Type: application/json" -d '{"name":"Alice"}'
# HTTP/1.1 403
# 3. POST with the token in the header: accepted
curl -i -b jar.txt -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: k3J..." \
-d '{"name":"Alice"}'
# HTTP/1.1 201CSRF with Thymeleaf Forms
Thymeleaf integrates with Spring Security. When you use th:action on a form with a non-GET method, Thymeleaf automatically adds a hidden _csrf input, so ordinary forms need no extra work.
If you use a plain action attribute instead of th:action, nothing is added and the form will be rejected with 403. In that case insert the hidden field yourself, as in the second example.
For AJAX calls from a Thymeleaf page, expose the token and header name in meta tags in the page head and read them in JavaScript. The example sends a JSON POST to /users, which is a normal session-protected endpoint.
Remember that the default logout in Spring Security 6 also requires a POST with a CSRF token. A plain link to /logout no longer works; use a small form with th:action="@{/logout}" and method post.
html
<!-- 1. th:action adds the hidden _csrf field automatically -->
<form th:action="@{/users}" method="post">
<input type="text" name="name" />
<input type="email" name="email" />
<button type="submit">Create User</button>
</form>
<!-- 2. Plain action attribute: add the token yourself -->
<form action="/users" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="text" name="name" />
<button type="submit">Create User</button>
</form>
<!-- 3. Logout must be a POST with a token -->
<form th:action="@{/logout}" method="post">
<button type="submit">Log out</button>
</form>
<!-- 4. AJAX from a Thymeleaf page: expose the token in the head -->
<head>
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
</head>
<script>
const token = document.querySelector('meta[name="_csrf"]').content;
const header = document.querySelector('meta[name="_csrf_header"]').content;
fetch('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[header]: token // X-CSRF-TOKEN: <token>
},
body: JSON.stringify({ name: 'Alice' })
});
</script>CSRF for SPAs: Cookie-Based Token (Server Configuration)
A single-page application (React, Angular, Vue) that authenticates with a session cookie needs CSRF protection too. The usual approach is CookieCsrfTokenRepository.withHttpOnlyFalse(). The server stores the token in a cookie named XSRF-TOKEN that JavaScript can read, and the SPA copies the value into an X-XSRF-TOKEN header on every state-changing request.
Spring Security 6 changed how request tokens are handled, and this is the most common reason an SPA gets 403 even though the cookie and header look right. The default handler expects a masked (BREACH-protected) token, but your JavaScript sends the raw value from the cookie, so validation fails. There are two ways to fix it.
The simple version below uses CsrfTokenRequestAttributeHandler, which accepts the raw token, and sets the attribute name to null so the token is loaded eagerly. Without that last line the token is only created when something reads it, so the cookie might never be written on the first page load. This drops BREACH masking, which is generally acceptable when the token is only delivered through a cookie and not rendered into compressed HTML pages.
The recommended version, shown next, keeps masking for rendered tokens and accepts the raw value from the header.
Both versions assume the SPA and the API are served from the same origin (or the same site behind one reverse proxy). JavaScript can only read cookies set for its own site, so a SPA on one origin cannot read the XSRF-TOKEN cookie set by an API on a different origin. In that case fetch the token from an endpoint such as /csrf instead.
Java
// Simple version: raw token accepted, eager loading so the cookie is always written
@Bean
public SecurityFilterChain spaChain(HttpSecurity http) throws Exception {
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null); // load the token eagerly
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(requestHandler))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}CSRF for SPAs: Recommended Handler with BREACH Protection
This is the pattern from the Spring Security reference documentation. The handler accepts the raw token when it arrives in the header (the SPA case) and the masked token when it arrives as a form parameter. The filter forces the deferred token to load so the XSRF-TOKEN cookie is written on every response.
One more detail: Spring Security replaces the CSRF token when a user logs in, to prevent session fixation issues. A SPA must therefore re-read the cookie (or call /csrf) after login, instead of caching the token it had before authenticating.
Java
@Bean
public SecurityFilterChain spaChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
Supplier<CsrfToken> csrfToken) {
xor.handle(request, response, csrfToken); // keeps masking for tokens rendered in pages
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());
return (StringUtils.hasText(headerValue) ? plain : xor)
.resolveCsrfTokenValue(request, csrfToken); // raw from header, masked from forms
}
}
final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
if (token != null) {
token.getToken(); // reading the token makes Spring write the cookie
}
chain.doFilter(request, response);
}
}CSRF for SPAs: Client-Side Code
On the client, read the XSRF-TOKEN cookie and send its value in the X-XSRF-TOKEN header on POST, PUT, PATCH and DELETE requests.
Angular's HttpClient does this automatically: it reads the XSRF-TOKEN cookie and adds X-XSRF-TOKEN to mutating requests, with no setup, which matches Spring's cookie and header names. One limitation: Angular only adds the header for requests to relative URLs. Calls to an absolute URL on another origin are skipped, so keep the API on the same origin or add the header yourself.
For React, Vue or plain fetch, add the header yourself. Decode the cookie value, because cookie values may be URL-encoded. Include credentials so the session cookie is sent. The default for same-origin requests already includes cookies, and credentials: 'include' is only needed for cross-origin calls, which also require matching CORS settings on the server.
javascript
function getCookie(name) {
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
async function createUser(user) {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCookie('XSRF-TOKEN') // read again after login: the token changes
},
credentials: 'same-origin', // sends the session cookie
body: JSON.stringify(user)
});
if (response.status === 403) {
throw new Error('CSRF token missing or invalid');
}
return response.json();
}Disabling CSRF for Stateless REST APIs
A stateless REST API that authenticates with a bearer token in the Authorization header does not need CSRF protection, because a malicious site cannot make the browser add that header. Disabling CSRF removes the CsrfFilter from the chain.
Only do this when the credential is truly not sent automatically. If the token is stored in a cookie, or the API accepts HTTP Basic credentials from a browser, keep CSRF enabled. Also remember to set the session policy to STATELESS so no session cookie exists to be abused in the first place.
Java
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // OK: auth is a Bearer header, not a cookie
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}Excluding Specific Paths from CSRF
When one application serves both a browser UI and a bearer-token API, you can exclude only the API paths with ignoringRequestMatchers. Everything else, such as form pages, stays protected. This is only safe if the excluded endpoints really do not use cookie authentication. If /api/** also accepts session cookies, excluding it leaves it open to CSRF.
A cleaner solution for mixed applications is two separate security filter chains with different securityMatcher values, one stateless with CSRF disabled for the API and one session-based with CSRF enabled for the web UI. See the Security Configuration page for the multi-chain pattern.
Do not use requireCsrfProtectionMatcher for this purpose. It replaces the default matcher completely, including the built-in rule that skips GET, HEAD, OPTIONS and TRACE. A custom matcher that simply returns true for non-API paths will make Spring demand a CSRF token on every GET request too, and page loads start failing with 403. ignoringRequestMatchers keeps the default behavior and only adds exceptions.
Be careful excluding webhook endpoints: they cannot send a token, so authenticate them another way, such as verifying the provider's HMAC signature.
Java
@Bean
public SecurityFilterChain mixedFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers(
"/api/**", // bearer-token API: no cookie auth
"/webhooks/stripe")) // verified by signature instead
.authorizeHttpRequests(auth -> auth
.requestMatchers("/webhooks/**").permitAll() // access control is the signature check
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}Customizing the 403 Response
When the CSRF check fails, Spring Security passes a CsrfException (either MissingCsrfTokenException or InvalidCsrfTokenException) to the AccessDeniedHandler. If you want a clear JSON body for API or SPA clients, register your own handler, and fall back to the default behavior for other access-denied cases. Log the failure on the server for diagnosis, but do not reveal token values in the response.
Java
@Component
public class CsrfAwareAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger log = LoggerFactory.getLogger(CsrfAwareAccessDeniedHandler.class);
private final AccessDeniedHandler fallback = new AccessDeniedHandlerImpl();
private final ObjectMapper mapper;
public CsrfAwareAccessDeniedHandler(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException ex) throws IOException, ServletException {
if (ex instanceof CsrfException) {
log.warn("CSRF rejection for {} {}: {}", request.getMethod(),
request.getRequestURI(), ex.getClass().getSimpleName());
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(response.getOutputStream(), Map.of(
"status", 403,
"error", "csrf_token_invalid",
"message", "The CSRF token is missing or invalid. Refresh and try again."));
} else {
fallback.handle(request, response, ex);
}
}
}
// In the filter chain:
// http.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler));Testing CSRF Behavior
Test both sides: that a request without a token is rejected, and that a request with a valid token succeeds. spring-security-test provides csrf() for MockMvc, which adds a valid token to the request. Use asHeader() to send it as a header, and useInvalidToken() to check that a wrong token is rejected.
The most common surprise in tests is a POST returning 403 because the test forgot .with(csrf()). That usually means the protection is working, not that something is broken.
Java
@SpringBootTest
@AutoConfigureMockMvc
class CsrfProtectionTest {
@Autowired
MockMvc mockMvc;
private static final String BODY = "{\"name\":\"Alice\"}";
@Test
void postWithoutTokenIsRejected() throws Exception {
mockMvc.perform(post("/users")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isForbidden());
}
@Test
void postWithValidTokenIsAccepted() throws Exception {
mockMvc.perform(post("/users")
.with(user("alice"))
.with(csrf()) // adds a valid token (as a parameter)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isCreated());
}
@Test
void tokenInHeaderIsAccepted() throws Exception {
mockMvc.perform(post("/users")
.with(user("alice"))
.with(csrf().asHeader()) // sent as X-CSRF-TOKEN
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isCreated());
}
@Test
void invalidTokenIsRejected() throws Exception {
mockMvc.perform(post("/users")
.with(user("alice"))
.with(csrf().useInvalidToken())
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isForbidden());
}
@Test
void getRequestsNeedNoToken() throws Exception {
mockMvc.perform(get("/users").with(user("alice")))
.andExpect(status().isOk());
}
}Common Mistakes and Unexplained 403 Errors
1. Disabling CSRF because POST requests return 403. A 403 on POST usually means the token is missing, which is the protection doing its job. Fix the client to send the token. Disable CSRF only after confirming the endpoint uses non-automatic credentials.
2. Disabling CSRF while keeping cookie authentication. A JWT in a cookie or a session cookie is sent automatically, so CSRF protection must stay on.
3. Forgetting that HTTP Basic is also sent automatically by browsers. Do not disable CSRF for browser-facing endpoints just because they use Basic auth.
4. Using the default token repository with SessionCreationPolicy.STATELESS. The default stores the token in the session, so there is nowhere to keep it. Use a cookie repository or disable CSRF for header-based authentication.
5. SPA sends the cookie value but still gets 403. In Spring Security 6 the default handler expects a masked token. Use CsrfTokenRequestAttributeHandler or the SpaCsrfTokenRequestHandler shown above.
6. XSRF-TOKEN cookie never appears. The token is deferred and only created when read. Add the eager-loading setting or the CsrfCookieFilter.
7. Using requireCsrfProtectionMatcher with a custom lambda. It replaces the default safe-method exclusion, so GET requests start requiring tokens. Use ignoringRequestMatchers instead.
8. A link to /logout stopped working. Logout is a POST that needs a token. Use a form.
9. Plain action attribute in a Thymeleaf form. Only th:action adds the hidden field. Use th:action or add the field manually.
10. Not refreshing the token after login. The token changes when the user authenticates. Re-read the cookie or fetch /csrf again.
11. Multipart uploads failing with 403. The token is not found in the multipart body in some setups. Send it in the X-CSRF-TOKEN header from JavaScript, or add it to the form action URL as a query parameter.
12. Changing state on GET. Safe methods are not checked. A GET endpoint that deletes or modifies data is exploitable by a simple image tag, whatever your CSRF configuration says.
13. Relying on CORS as CSRF protection. CORS restricts which sites can read responses; it does not stop a browser from sending a simple cross-site form POST.
Frequently Asked Questions
Is CSRF protection enabled by default in Spring Boot?
Yes, when Spring Security is on the classpath. The CsrfFilter protects POST, PUT, PATCH and DELETE requests and skips GET, HEAD, OPTIONS and TRACE.
Do I need CSRF protection for a REST API?
Not if clients authenticate with a bearer token in the Authorization header and no cookie or Basic credentials are involved. You do need it when a browser authenticates with cookies, including a JWT stored in a cookie.
Why do I get 403 Forbidden on POST but GET works?
CSRF protection only checks non-safe methods. A POST without a valid token is rejected with 403. Send the token in the _csrf parameter or the X-CSRF-TOKEN header, or, in tests, add .with(csrf()).
Where does Spring Security store the CSRF token?
By default in the HttpSession. With CookieCsrfTokenRepository it is stored in a cookie named XSRF-TOKEN, which a SPA can read and echo back in the X-XSRF-TOKEN header.
What is the difference between X-CSRF-TOKEN and X-XSRF-TOKEN?
They are just different default names. The session-based default repository uses the header X-CSRF-TOKEN and the parameter _csrf. The cookie repository uses the cookie XSRF-TOKEN and the header X-XSRF-TOKEN, which matches what Angular does automatically.
Is it safe to disable CSRF with JWT?
It is safe when the JWT travels in the Authorization header and your own JavaScript adds it. It is not safe when the JWT lives in a cookie, because the browser then sends it automatically.
Does SameSite make CSRF tokens unnecessary?
No. SameSite=Lax or Strict greatly reduces the risk and is a good extra layer, but it is not a complete replacement. Browser behavior differs, and same-site subdomains can still be a source of attacks. Keep tokens for session-based applications.
How do I get the CSRF token in a controller?
Add a CsrfToken parameter to the controller method. Spring resolves it from the request, as in the /csrf endpoint example. In Thymeleaf templates it is available as the _csrf variable.
Does CSRF protection apply to the login form?
Yes. The login POST needs a token too, and Spring's generated login page and Thymeleaf's th:action include it automatically. A custom login page must include the hidden field.
Can XSS bypass CSRF protection?
Yes. If an attacker can run script on your page, they can read the token and make valid requests. CSRF protection and XSS prevention (output encoding, Content-Security-Policy) address different attacks, and you need both.