CSRF and Stateless APIs

Understand what CSRF protects against, when a token-based API can safely turn it off, and how to keep it working for cookie-based single-page apps.

Syntax// Token API (Authorization header, no cookies) http.csrf(csrf -> csrf.disable()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // Browser app with a session cookie http.csrf(csrf -> csrf.spa());

Cross-site request forgery abuses the fact that browsers attach cookies automatically. If a user is logged in to your API with a session cookie and visits a malicious page, that page can submit a form to https://api.example.com/api/transfers, and the browser sends the cookie along. Your server sees a properly authenticated request the user never meant to make.

Spring Security's defence is a CSRF token: a random value the legitimate front end must send with every state-changing request (POST, PUT, PATCH, DELETE). A page on another site cannot read it, so its forged request fails with 403. Protection is on by default, which is why your first POST after adding the starter is rejected even with valid credentials.

When disabling it is correct

The attack only works when the browser adds the credential by itself. A stateless API that authenticates every request with an Authorization: Bearer ... header (or HTTP Basic sent by a non-browser client) is not exposed, because a malicious page cannot make the browser add that header. For such an API, disable CSRF and make the API stateless in the same breath:

http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

STATELESS tells Spring Security never to create an HTTP session or store the security context in one. Each request must authenticate on its own, which is exactly what tokens do, and the application scales horizontally without sticky sessions.

When you must keep it

Keep CSRF protection whenever a browser authenticates with cookies: server-rendered pages, a single-page app that logs in with a session, or a backend-for-frontend that keeps tokens server-side and gives the browser a session cookie. For a single-page app, csrf.spa() configures the usual pattern: the token is written to a readable XSRF-TOKEN cookie and the front end echoes it in an X-XSRF-TOKEN header. Angular does this automatically; with fetch you copy the cookie value yourself. The cookie is written lazily, only when something loads the token, and it is replaced at login. Give the front end a small endpoint that takes a CsrfToken parameter and reads it, and call it after login, or the first write fails with 403.

Mistakes that open holes

  • Disabling CSRF while still using session cookies, because a tutorial for token APIs said so. This is the real vulnerability; the check exists for exactly this setup.
  • Storing a JWT in a cookie and disabling CSRF. A cookie is a cookie: the browser sends it automatically, so the token gains nothing over a session against CSRF.
  • Changing state on GET. CSRF protection deliberately skips safe methods, so a GET /api/orders/9/cancel can be triggered by an image tag.

SameSite=Lax or Strict cookies add a second layer, but older browsers and same-site subdomains weaken it, so treat it as defence in depth, not a replacement.

Example

Example · java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Configuration
public class CsrfConfig {

    // Machine clients: bearer tokens or Basic auth in a header, never cookies.
    @Bean
    @Order(1)
    SecurityFilterChain partnerApi(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/partner-api/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("PARTNER"))
            .httpBasic(Customizer.withDefaults())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    // The browser app logs in with a session cookie, so CSRF stays on.
    @Bean
    @Order(2)
    SecurityFilterChain browserApp(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/error").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            // XSRF-TOKEN cookie, read by the front end and sent back as X-XSRF-TOKEN.
            .csrf(csrf -> csrf.spa());
        return http.build();
    }
}

@RestController
class CsrfController {

    // Loading the token makes Spring Security write the XSRF-TOKEN cookie.
    // The front end calls this after login, before its first write.
    @GetMapping("/api/csrf")
    void csrf(CsrfToken token) {
        token.getToken();
    }
}

When to use it

  • Turn off CSRF safely for a stateless API used by mobile apps and partner servers with bearer tokens.
  • Keep CSRF on for a single-page app that logs in with a session cookie and sends the X-XSRF-TOKEN header.
  • Run a token API and a cookie-based admin UI in one application with two filter chains.

More examples

Hardening the session cookie

<code>SameSite=Lax</code> stops the browser sending the session cookie on cross-site <code>POST</code> requests, <code>Secure</code> keeps it off plain HTTP, and <code>HttpOnly</code> hides it from JavaScript so an XSS bug cannot steal it. These complement the CSRF token rather than replacing it. Note that the <code>XSRF-TOKEN</code> cookie is deliberately not <code>HttpOnly</code>: the front end has to read it.

Example · properties
# Defence in depth for cookie-based sessions.
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.timeout=30m

Sending the token from fetch

The server sets the <code>XSRF-TOKEN</code> cookie; the page reads it and sends it back in the header. A malicious site can make the browser send the cookie but cannot read it, so it cannot fill in the header. The helper reads the cookie on every call because the token changes at login. A <code>403</code> on a write usually means the cookie was never written or is stale: call the <code>/api/csrf</code> endpoint to get a fresh cookie, then retry once.

Example · text
// Front-end helper (JavaScript) for a cookie-authenticated Spring Boot API.
function csrfToken() {
  const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/);
  return match ? decodeURIComponent(match[1]) : '';
}

async function cancelOrder(id) {
  const res = await fetch(`/api/orders/${id}/cancel`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'X-XSRF-TOKEN': csrfToken() },
  });
  if (res.status === 403) {
    // Token missing or stale: GET /api/csrf for a fresh cookie, then retry once.
  }
  return res;
}

A forged request in practice

This is what an auto-submitting form on another site produces. The browser adds the session cookie, so without CSRF protection the transfer would succeed. With protection on, the request has no valid token and Spring Security rejects it with <code>403</code> before your controller runs. The same request against a stateless bearer-token API fails with <code>401</code>, because there is nothing for the browser to add.

Example · http
POST /api/transfers HTTP/1.1
Host: bank.example.com
Origin: https://evil.example.net
Cookie: JSESSIONID=4F2C...   (added by the browser automatically)
Content-Type: application/x-www-form-urlencoded

to=attacker&amountPence=50000

HTTP/1.1 403 Forbidden

Discussion

  • Be the first to comment on this lesson.