CSRF is on by default — don't switch it off
From Spring Security’s own documentation:
Spring Security protects against CSRF attacks by default for unsafe HTTP methods, such as a POST request, so no additional code is necessary.
This is the opposite of Express, where there is no built-in protection and you must choose a library. Here the work is not turning it off, and the reason that needs a page is that turning it off is a one-liner that appears in a great many tutorials and Stack Overflow answers:
// The line to grep for.http.csrf(csrf -> csrf.disable());Invalid CSRF token found for https://example.com/api/ordersThe filter saw a token and rejected it. Logged at DEBUG, which is the part that matters — at default production logging this line does not appear, so the client gets a 403 and the server log shows nothing.
Read in: spring-security web/.../csrf/CsrfFilter.javaCould not verify the provided CSRF token because no token was found to compare.No token was present at all, as opposed to a wrong one. The usual cause is a client that never read the cookie — an SPA calling a JSON endpoint, rather than a form that would have carried the hidden field.
Read in: spring-security web/.../csrf/MissingCsrfTokenException.javaInvalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.The exception message behind the 403, naming both places it looked. A literal 'null' means the client sent the parameter with no value, which points at a template that rendered an empty token rather than at a client that omitted it.
Read in: spring-security web/.../csrf/InvalidCsrfTokenException.javaWhy it gets disabled
Section titled “Why it gets disabled”Almost always to make something work, not because anyone decided the risk was acceptable:
- a JavaScript front end started getting
403s on POST; - Postman worked but the browser did not;
- a REST API “does not need CSRF”.
The third is the only one with a real argument behind it, and it is narrower than it
sounds. CSRF exists because browsers attach credentials automatically. An API
authenticated purely by an Authorization: Bearer header that the browser does not
attach on its own is genuinely not CSRF-exploitable. An API authenticated by a
session cookie is — regardless of whether it returns JSON.
So the question is not “is this a REST API” but “does the browser send credentials without my code asking”. If you use cookies, keep CSRF on.
// Single-page app: hand the token to JavaScript via a cookie.http.csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));CookieCsrfTokenRepository writes a cookie named XSRF-TOKEN and reads the value
back from the X-XSRF-TOKEN header or the _csrf parameter. That is the convention
Angular and most HTTP clients already implement, which usually makes the 403s
disappear without disabling anything.
The default repository stores the token in the HttpSession instead, which is correct
for server-rendered forms and is why Thymeleaf forms need no extra work.
If you genuinely have a stateless token API, scope the exemption rather than removing the protection:
http.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"));curl -si -X POST https://example.com/account/email \ -H 'Cookie: JSESSIONID=<valid-session>' \ -d 'email=attacker@example.test' | head -1A 403 is the correct result: a session-authenticated POST with no CSRF token must be
rejected. A 200 means the protection is off.
Two behaviours that surprise people
Section titled “Two behaviours that surprise people”Spring Security 6 changed two things that are easy to misread as bugs:
- Token loading is deferred by default, so the session is not loaded on every request. A token you expected to exist may not have been generated yet.
- The token includes randomness on every request, as BREACH protection, via
XorCsrfTokenRequestAttributeHandler. So the value differs between requests by design — code that caches or compares raw token values will misbehave.
Both are documented defaults, not faults, and both have produced “CSRF is broken”
conclusions that ended in csrf.disable().
Keeping CSRF on breaks any client that posts without a token — which is the reason it
gets disabled in the first place. Fix the client rather than the server: read the
XSRF-TOKEN cookie and send it back as X-XSRF-TOKEN.
Enabling it on an application that has run with it disabled will break integrations
you did not know existed. Scope with ignoringRequestMatchers, verify the exempt
routes are genuinely not cookie-authenticated, and shrink the exemption over time.
Related
Section titled “Related”- SecurityFilterChain — the bean this line lives in
- Express: csurf is archived — the same problem where the framework gives you nothing
- Django CSRF trusted origins — also on by default, configured differently