CSRF Protection
How CSRF works, how Spring CsrfFilter stops it, SameSite cookies as a second layer, the double-submit pattern for SPAs, and exactly when disabling CSRF is correct.
On this page
Cross-site request forgery exploits a browser behaviour that is otherwise convenient: cookies are attached to requests automatically, regardless of which site initiated them. The site the user is logged into cannot tell the difference between a click on its own page and a form on an attacker's.
Key Takeaways
- CSRF exploits ambient credentials — cookies attached automatically by the browser.
- If authentication is a header, there is no CSRF risk. Headers are never automatic.
- Spring's
CsrfFilteris on by default and protectsPOST,PUT,PATCHandDELETE. - SPAs need the double-submit cookie pattern so JavaScript can read the token.
SameSite=Laxis a strong second layer, not a replacement for tokens.
The attack
Note what the attacker does not need. They never read the response — the same-origin policy blocks that. They never steal the cookie. They only need the request to be sent, which is enough when the request itself is the damaging action.
How the token defeats it
The server issues a random token, renders it into the page, and requires it on every state-changing request. The attacker cannot read the token — that would require reading a cross-origin response, which the same-origin policy forbids — so they cannot construct a valid request.
Spring enables this by default. CsrfFilter validates POST, PUT, PATCH and DELETE, and
deliberately skips GET, HEAD, OPTIONS and TRACE because those are supposed to be safe. Which
leads to a rule worth stating plainly: a GET that changes state has no CSRF protection at all,
in any framework. That is one of several reasons not to write one.
With Thymeleaf, the token is injected into any form using th:action automatically:
<form th:action="@{/transfer}" method="post">
<input type="text" name="amount"/>
<!-- <input type="hidden" name="_csrf" value="..."/> is added for you -->
<button type="submit">Transfer</button>
</form>Single-page applications
An SPA cannot read a server-rendered hidden field. The double-submit cookie pattern solves it: the token goes into a cookie the script can read, and the script echoes it in a header. An attacker's page can cause the cookie to be sent but cannot read it to set the header.
@Bean
SecurityFilterChain spaChain(HttpSecurity http) throws Exception {
// HttpOnly must be false: the SPA has to read this cookie.
var repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookiePath("/");
// In Spring Security 6 the token is deferred (loaded lazily) by default,
// which breaks the cookie pattern. This handler restores eager rendering.
var requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http
.csrf(csrf -> csrf
.csrfTokenRepository(repository)
.csrfTokenRequestHandler(requestHandler)
// Endpoints with no cookie authentication need no CSRF token.
.ignoringRequestMatchers("/api/webhooks/**"))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
return http.build();
}
/** Forces the deferred token to materialise so the cookie is actually written. */
final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
token.getToken(); // triggers the deferred load
chain.doFilter(request, response);
}
}The client side is two lines:
const csrf = document.cookie.split('; ')
.find(c => c.startsWith('XSRF-TOKEN='))?.split('=')[1];
await fetch('/api/v1/orders', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': decodeURIComponent(csrf) },
body: JSON.stringify(order),
});SameSite cookies
SameSite tells the browser whether to attach a cookie to cross-site requests, which attacks the
problem at its root.
| Value | Behaviour |
|---|---|
Strict | Never sent cross-site, including top-level navigation |
Lax | Sent on top-level GET navigation only — the modern default |
None | Always sent; requires Secure |
server:
servlet:
session:
cookie:
same-site: lax
http-only: true
secure: trueLax blocks the classic auto-submitting POST outright. It is genuinely strong protection — but keep
tokens as well. Lax permits top-level GET, subdomains count as same-site, and older clients may
ignore the attribute. Two independent layers is the correct posture for something this cheap.
When disabling is correct
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
// Safe here, and only here: bearer-token auth with no cookies.
// A browser never attaches an Authorization header on its own, so a
// cross-site request carries no credential and achieves nothing.
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
return http.build();
}The condition is precise, and worth restating because it is regularly misapplied: CSRF protection may be disabled when no endpoint in that chain authenticates via a cookie. "It is a REST API" is not the condition — a REST API that accepts a session cookie is exactly as vulnerable as a form.
A common mistake is disabling CSRF globally because the JSON API does not need it, while a server-rendered admin console shares the same application and the same session cookie. Separate filter chains, as above, keep the decision scoped to where it is true.
Webhooks
Inbound webhooks need CSRF excluded, since the sender cannot obtain a token. Replace it with a real authentication mechanism rather than leaving the endpoint open: verify an HMAC signature over the request body using a shared secret, and reject anything with a stale timestamp so a captured request cannot be replayed later.
Testing
@Test
void rejectsPostWithoutCsrfToken() throws Exception {
mvc.perform(post("/transfer").with(user("alice")).param("amount", "100"))
.andExpect(status().isForbidden());
}
@Test
void acceptsPostWithCsrfToken() throws Exception {
mvc.perform(post("/transfer").with(user("alice")).with(csrf()).param("amount", "100"))
.andExpect(status().isOk());
}Test both directions. A test that only asserts the happy path passes just as well when CSRF is accidentally disabled — which is precisely the regression you want to catch.
What to take away
CSRF is about ambient credentials, so the question is always "is a cookie authenticating this
request". If yes, keep tokens on and add SameSite=Lax. If authentication is a bearer header and no
cookies are involved, disabling is correct — but scope that decision to its own filter chain so it
cannot silently apply to a session-based part of the same application.
Frequently Asked Questions
Can I disable CSRF for a REST API?
Is SameSite=Lax enough on its own?
Why does my SPA get 403 on every POST?
Related tutorials
- CORS ConfigurationUnderstanding the same-origin policy, what preflight actually checks, configuring CORS in Spring correctly, and why allowedOrigins star with credentials is refused.
- HTTP Security HeadersEvery security header worth setting: what each one prevents, the values to use, Spring configuration, and how to roll out a Content Security Policy without breaking the site.
- SSL/TLS & HTTPS in Spring BootConfiguring TLS properly: the handshake, keystores and PKCS12, HTTP to HTTPS redirect, mutual TLS for service-to-service, cipher policy, and where to terminate.
- SQL Injection PreventionHow SQL injection actually works, why parameterised queries stop it, the JPA and JdbcTemplate patterns that are safe, the ones that are not, and how to test for it.