CSRF Protection: How It Works and When to Disable It

What Is a CSRF Attack?

Cross-Site Request Forgery (CSRF) tricks an authenticated user’s browser into making an unintended request to your application.

The attack:

  1. Alice is logged into bank.com — her browser holds a valid session cookie
  2. Alice visits evil.com
  3. evil.com contains <img src="https://bank.com/transfer?to=attacker&amount=5000">
  4. Alice’s browser fires the request, automatically attaching her bank.com session cookie
  5. bank.com receives an authenticated request that Alice never intended to make

The attack works because browsers automatically send cookies with cross-origin requests. CSRF protection adds a secret token that evil.com cannot read (due to the Same-Origin Policy) and that must be present for state-changing requests.


The Synchronizer Token Pattern

Spring Security’s default CSRF defense:

  1. When a session is created, generate a random CSRF token and store it in the session
  2. Include the token in every response (as a hidden form field or cookie)
  3. For every state-changing request (POST, PUT, PATCH, DELETE), require the token in the request
  4. Compare the submitted token against the session-stored token — reject if they do not match

evil.com can trigger a cross-origin request, but it cannot read the CSRF token from your page (SOP blocks that), so it cannot include the correct token, and the request is rejected.

sequenceDiagram
    Browser->>Server: GET /transfer-form
    Server->>Browser: HTML with 
    Browser->>Server: POST /transfer (body: to=alice&amount=100&_csrf=abc123)
    Server->>Server: Compare abc123 with session token ✓
    Server->>Browser: 200 OK

    Note over Browser,Server: Attack scenario
    EvilSite->>Server: POST /transfer (body: to=attacker&amount=5000)
    Server->>Server: No _csrf token → reject
    Server->>EvilSite: 403 Forbidden

CSRF Is On by Default

Spring Security enables CSRF protection by default for all state-changing HTTP methods. No configuration needed for standard form-based applications.

// CSRF is active without any explicit configuration:
http
    .formLogin(Customizer.withDefaults())
    .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());

Form-Based Applications

Thymeleaf

Thymeleaf automatically includes the CSRF token in forms:

<form th:action="@{/transfer}" method="post">
    <!-- Thymeleaf inserts: <input type="hidden" name="_csrf" value="..."> -->
    <input type="text" name="to">
    <input type="number" name="amount">
    <button type="submit">Transfer</button>
</form>

Plain HTML / JSP

Include the token manually:

<form action="/transfer" method="post">
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
    <input type="text" name="to">
    <button type="submit">Transfer</button>
</form>

Accessing the Token in a Controller

@GetMapping("/transfer-form")
public String transferForm(HttpServletRequest request, Model model) {
    CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
    model.addAttribute("_csrf", csrfToken);
    return "transfer-form";
}

Single-Page Applications (SPAs)

SPAs make AJAX requests — they cannot use hidden form fields. Use the cookie-to-header pattern.

CookieCsrfTokenRepository

Configure Spring Security to write the CSRF token to a cookie:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        );
    return http.build();
}

withHttpOnlyFalse() makes the cookie readable by JavaScript (necessary so the SPA can read and include it in request headers).

Reading and Sending from the SPA

// Read the token from the cookie
function getCsrfToken() {
    return document.cookie
        .split(';')
        .map(c => c.trim())
        .find(c => c.startsWith('XSRF-TOKEN='))
        ?.split('=')[1];
}

// Include it in every state-changing request
async function transfer(to, amount) {
    const response = await fetch('/transfer', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-XSRF-TOKEN': getCsrfToken()
        },
        body: JSON.stringify({ to, amount })
    });
    return response.json();
}

Spring Security’s CsrfFilter checks the X-XSRF-TOKEN header. evil.com cannot read the cookie (SOP applies to JavaScript reading cookies from other origins), so the attack fails.

Axios Interceptor

If using Axios, configure the interceptor once:

axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';
// Axios reads and sends the token automatically for all requests

Custom CSRF Token Repository

Implement CsrfTokenRepository for custom storage (e.g., database-backed tokens):

public class DatabaseCsrfTokenRepository implements CsrfTokenRepository {

    private final CsrfTokenStore store;  // your persistence layer

    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", generateRandomToken());
    }

    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        String sessionId = request.getSession().getId();
        if (token == null) {
            store.delete(sessionId);
        } else {
            store.save(sessionId, token.getToken());
        }
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        String sessionId = request.getSession(false) != null
            ? request.getSession().getId()
            : null;
        if (sessionId == null) return null;
        String token = store.find(sessionId);
        return token != null ? new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token) : null;
    }

    private String generateRandomToken() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
}

When to Disable CSRF

CSRF only matters when the browser automatically attaches credentials (session cookies). Stateless APIs authenticating with JWT Bearer tokens are not vulnerable to CSRF — there is no cookie for the browser to attach.

Safe to disable:

  • REST APIs using JWT or API keys in the Authorization header (not cookies)
  • Webhooks receiving callbacks from external services
  • Server-to-server communication (no browser involved)

Not safe to disable:

  • Form-based web applications using session cookies
  • APIs called from browsers that use cookie-based sessions
  • Applications using remember-me cookies
// Correct: disable CSRF for a stateless JWT API
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .csrf(csrf -> csrf.disable())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}
// WRONG: disabling CSRF for a form-login application
http.csrf(csrf -> csrf.disable());  // opens the application to CSRF attacks

Excluding Specific Endpoints from CSRF

Sometimes you need to exclude specific paths — for example, a webhook endpoint that must accept unauthenticated POST requests from an external service:

http.csrf(csrf -> csrf
    .ignoringRequestMatchers("/webhooks/**", "/api/v1/public/**")
);

Only exclude paths that do not perform state-changing operations on behalf of authenticated users. Webhooks typically verify a signature in the payload or a header instead of relying on CSRF tokens.


CSRF and Spring Security 6 Changes

Spring Security 6 made one important change: CsrfFilter now delays loading the CSRF token until it is actually needed (lazy token loading). This avoids creating sessions on every request just to store a CSRF token.

If you use CookieCsrfTokenRepository, you need to explicitly subscribe to the token to ensure it is written to the response:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
    );
    return http.build();
}

XorCsrfTokenRequestAttributeHandler was introduced in Spring Security 6 to protect against BREACH attacks by XOR-masking the token before writing it to the response.


Key Takeaways

  • CSRF exploits session cookies — it is not a concern for stateless JWT APIs
  • Spring Security’s Synchronizer Token Pattern generates a random token per session, requires it on every state-changing request
  • Thymeleaf automatically includes CSRF tokens; plain HTML requires explicit <input type="hidden">
  • SPAs use CookieCsrfTokenRepository with withHttpOnlyFalse() and send the token in X-XSRF-TOKEN
  • Disable CSRF only for stateless APIs using Authorization headers — never for cookie-based applications
  • In Spring Security 6, use XorCsrfTokenRequestAttributeHandler to protect against BREACH attacks

Next: CORS: Cross-Origin Requests and Preflight Configuration — configure CORS correctly so browsers allow your SPA to call your API without disabling security.