Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate5 min readUpdated
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 CsrfFilter is on by default and protects POST, PUT, PATCH and DELETE.
  • SPAs need the double-submit cookie pattern so JavaScript can read the token.
  • SameSite=Lax is a strong second layer, not a replacement for tokens.

The attack

The browser attaches the session cookie to a cross-site request. Without a token the server has no way to tell where the request came from.

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:

transfer.html
<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.

CsrfConfig.java
@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:

api.js
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.

ValueBehaviour
StrictNever sent cross-site, including top-level navigation
LaxSent on top-level GET navigation only — the modern default
NoneAlways sent; requires Secure
application.yml
server:
  servlet:
    session:
      cookie:
        same-site: lax
        http-only: true
        secure: true

Lax 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

StatelessApi.java
@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

CsrfTest.java
@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?
Yes, if authentication comes from an Authorization header and the API uses no cookies. The attack depends on the browser automatically attaching a credential; a header is never attached automatically, so there is nothing to exploit. If any endpoint authenticates via cookie, CSRF protection must stay on.
Is SameSite=Lax enough on its own?
It is a strong layer and not a complete one. Lax still permits top-level GET navigation, so a GET that changes state remains exploitable. Older browsers may ignore it entirely, and subdomains are same-site. Treat it as defence in depth alongside tokens, not a replacement.
Why does my SPA get 403 on every POST?
The default CsrfTokenRepository stores the token server-side and your JavaScript cannot read it. Switch to CookieCsrfTokenRepository with HttpOnly disabled so the script can read the cookie and echo it in the X-XSRF-TOKEN header — the double-submit pattern.

Related tutorials