Spring Security: CSRF Protection & CORS Configuration

CSRF (Cross-Site Request Forgery) and CORS (Cross-Origin Resource Sharing) are two separate browser security concerns that Spring Security handles differently. CSRF protects stateful sessions from forged requests; CORS controls which origins browsers allow to call your API. Both need deliberate configuration for REST APIs and SPAs.

Contents

How CSRF Works

Spring Security's CSRF protection works by issuing a server-side token that must be echoed back in every state-changing request (POST, PUT, DELETE). A malicious site cannot read the token from your session cookie due to the browser's Same-Origin Policy, so it cannot forge a valid request.

CSRF protection is enabled by default in Spring Security. For traditional server-rendered web apps with sessions it should stay on. For stateless REST APIs authenticated via JWT (no session, no cookies) it can safely be disabled.

Disabling CSRF for Stateless REST APIs

If your API is stateless (JWT in Authorization header, no session cookie), CSRF attacks are impossible: disable CSRF to avoid 403 Forbidden on mutating requests.

Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())                         // safe — no session cookies
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated());
        return http.build();
    }
}
Never disable CSRF in an application that uses session cookies for authentication. Only disable it when authentication relies on a stateless mechanism like a Bearer token in the Authorization header.

Cookie-Based CSRF Token (SPAs)

For SPAs (Angular, React) that use session cookies, use CookieCsrfTokenRepository. Spring writes the CSRF token into a JavaScript-readable cookie (XSRF-TOKEN); the SPA reads it and echoes it as an X-XSRF-TOKEN header on each mutating request.

Java
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // JS-readable
            .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler()))  // Spring 6 handler
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated());
    return http.build();
}
JavaScript
// Angular HttpClient automatically sends the X-XSRF-TOKEN header.
// For plain fetch in React / vanilla JS:
function getCookie(name) {
  return document.cookie.split('; ')
    .find(row => row.startsWith(name + '='))
    ?.split('=')[1];
}

fetch('/api/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-XSRF-TOKEN': getCookie('XSRF-TOKEN')
  },
  body: JSON.stringify(order)
});

How CORS Works

Browsers block cross-origin AJAX requests by default. When your React app on http://localhost:3000 calls your API on http://localhost:8080, the browser first sends a preflight OPTIONS request to check if the server permits it. Spring Security must handle CORS before authentication checks: otherwise the preflight (which carries no credentials) is rejected with 401.

Global CORS Configuration

Register a CorsConfigurationSource bean and enable it inside SecurityFilterChain. This applies CORS rules to every endpoint, and Spring Security processes the preflight before any auth check.

Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.example.com",
            "http://localhost:3000"          // dev frontend
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
        config.setExposedHeaders(List.of("X-Total-Count"));  // headers JS can read
        config.setAllowCredentials(true);   // required if frontend sends cookies
        config.setMaxAge(3600L);            // preflight cache TTL in seconds

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

Per-Endpoint @CrossOrigin

For fine-grained control on individual controllers or methods, use @CrossOrigin. It complements (not replaces) global config.

Java
import org.springframework.web.bind.annotation.*;

// Applied at class level — all methods in this controller
@CrossOrigin(origins = "https://partner.example.com", maxAge = 3600)
@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping
    public List<Product> list() { ... }

    // Override origin for one method
    @CrossOrigin(origins = "*")   // public endpoint — no credentials needed
    @GetMapping("/catalog")
    public List<Product> publicCatalog() { ... }
}
Do not use origins = "*" with allowCredentials = true: browsers reject that combination. Use explicit origin lists whenever cookies or Authorization headers are involved.

Combining CSRF & CORS in SecurityFilterChain

A typical production SecurityFilterChain for a stateless REST API with a separate SPA frontend:

Java
@Bean
public SecurityFilterChain apiSecurityChain(HttpSecurity http,
                                             CorsConfigurationSource corsSource) throws Exception {
    http
        // CORS — must be first, before auth
        .cors(cors -> cors.configurationSource(corsSource))

        // CSRF — disabled for stateless JWT auth
        .csrf(csrf -> csrf.disable())

        // No session — JWT is stateless
        .sessionManagement(sm -> sm
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))

        // Auth rules
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // always allow preflight
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated())

        // JWT filter before UsernamePasswordAuthenticationFilter
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}