Spring Boot 10 min read

Spring Security CSRF, CORS & Headers: Complete Protection Guide

Master CSRF protection, CORS configuration, and security headers in Spring Boot. Learn when to disable CSRF, configure CORS properly, and add security headers.

MR

Moshiour Rahman

Advertisement

Understanding Web Security Threats

Before diving into configuration, understand what these protections prevent:

ProtectionPreventsAttack Type
CSRFCross-Site Request ForgeryMalicious site tricks user’s browser into making requests
CORSUnauthorized cross-origin requestsScripts from other origins accessing your API
Security HeadersVarious attacksXSS, clickjacking, MIME sniffing, etc.

CSRF Protection

What is CSRF?

Cross-Site Request Forgery tricks authenticated users into performing unwanted actions.

1. User logs into bank.com (session cookie stored)
2. User visits malicious.com
3. malicious.com contains: <img src="bank.com/transfer?to=attacker&amount=1000">
4. Browser sends request WITH bank.com cookies
5. Bank processes transfer (user is authenticated)

Spring Security CSRF Protection

Spring Security includes CSRF protection by default. It works by:

  1. Generating a unique token per session
  2. Requiring that token on state-changing requests (POST, PUT, DELETE)
  3. Rejecting requests without valid token

Default CSRF Configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF is enabled by default
            .csrf(Customizer.withDefaults());

        return http.build();
    }
}

CSRF Token in Forms (Thymeleaf)

<form th:action="@{/transfer}" method="post">
    <!-- Token automatically included by Thymeleaf -->
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
    <input type="text" name="amount"/>
    <button type="submit">Transfer</button>
</form>

CSRF Token for JavaScript (SPA)

For single-page applications, configure CSRF token in cookies:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

        return http.build();
    }
}

// For SPAs that read CSRF from cookie
final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {

    private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            Supplier<CsrfToken> csrfToken) {
        this.delegate.handle(request, response, csrfToken);
    }

    @Override
    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
            return super.resolveCsrfTokenValue(request, csrfToken);
        }
        return this.delegate.resolveCsrfTokenValue(request, csrfToken);
    }
}

JavaScript: Reading and Sending CSRF Token

// Read CSRF token from cookie
function getCsrfToken() {
    const name = 'XSRF-TOKEN=';
    const cookies = document.cookie.split(';');
    for (let cookie of cookies) {
        cookie = cookie.trim();
        if (cookie.startsWith(name)) {
            return cookie.substring(name.length);
        }
    }
    return null;
}

// Include in fetch requests
fetch('/api/data', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-XSRF-TOKEN': getCsrfToken()  // Header name
    },
    body: JSON.stringify(data)
});

// Axios: Configure globally
axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';

When to Disable CSRF

Disable CSRF only for stateless APIs using token-based auth (JWT):

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // Safe for JWT-based stateless APIs
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        return http.build();
    }
}
ScenarioCSRFReason
Traditional web app with sessionsEnableBrowser sends cookies automatically
REST API with JWT in headerDisableToken not sent automatically
REST API with cookie-based JWTEnableCookie sent automatically
Mixed (web + API)SelectiveDisable for API paths only

Selective CSRF Disable

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf
            .ignoringRequestMatchers("/api/**")  // Disable for API
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        );

    return http.build();
}

CORS Configuration

What is CORS?

Cross-Origin Resource Sharing controls which origins can access your API.

Browser Security:
- Same-Origin Policy blocks cross-origin requests by default
- CORS headers tell browser "these origins are allowed"

Example:
- Your frontend: https://myapp.com
- Your API: https://api.myapp.com
- Without CORS: Browser blocks frontend → API requests
- With CORS: API says "myapp.com is allowed"

Basic CORS Configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()));

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // Allowed origins
        configuration.setAllowedOrigins(List.of(
            "http://localhost:3000",
            "https://myapp.com"
        ));

        // Allowed HTTP methods
        configuration.setAllowedMethods(List.of(
            "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
        ));

        // Allowed headers
        configuration.setAllowedHeaders(List.of(
            "Authorization",
            "Content-Type",
            "X-Requested-With",
            "Accept",
            "Origin",
            "Access-Control-Request-Method",
            "Access-Control-Request-Headers"
        ));

        // Expose headers to JavaScript
        configuration.setExposedHeaders(List.of(
            "Access-Control-Allow-Origin",
            "Access-Control-Allow-Credentials",
            "Authorization"
        ));

        // Allow credentials (cookies, authorization headers)
        configuration.setAllowCredentials(true);

        // Cache preflight response
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}

Environment-Specific CORS

@Configuration
public class CorsConfig {

    @Value("${app.cors.allowed-origins}")
    private List<String> allowedOrigins;

    @Value("${app.cors.allowed-methods}")
    private List<String> allowedMethods;

    @Value("${app.cors.max-age}")
    private Long maxAge;

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(allowedOrigins);
        configuration.setAllowedMethods(allowedMethods);
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(maxAge);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}
# application.yml
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:5173
    allowed-methods:
      - GET
      - POST
      - PUT
      - DELETE
    max-age: 3600

# application-prod.yml
app:
  cors:
    allowed-origins:
      - https://myapp.com
      - https://www.myapp.com
    allowed-methods:
      - GET
      - POST
      - PUT
      - DELETE
    max-age: 86400

Per-Controller CORS

@RestController
@RequestMapping("/api/public")
@CrossOrigin(origins = "*", maxAge = 3600)  // Allow all origins
public class PublicApiController {

    @GetMapping("/data")
    public ResponseEntity<Data> getData() {
        return ResponseEntity.ok(data);
    }
}

@RestController
@RequestMapping("/api/private")
@CrossOrigin(
    origins = {"https://myapp.com"},
    allowedHeaders = {"Authorization", "Content-Type"},
    allowCredentials = "true"
)
public class PrivateApiController {

    @GetMapping("/user")
    public ResponseEntity<User> getUser() {
        return ResponseEntity.ok(user);
    }

    @CrossOrigin(origins = "https://admin.myapp.com")  // Override class-level
    @DeleteMapping("/user/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        return ResponseEntity.noContent().build();
    }
}

CORS Filter (Alternative Approach)

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter implements Filter {

    @Value("${app.cors.allowed-origin}")
    private String allowedOrigin;

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletRequest request = (HttpServletRequest) req;

        response.setHeader("Access-Control-Allow-Origin", allowedOrigin);
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers",
            "Authorization, Content-Type, Accept, X-Requested-With");
        response.setHeader("Access-Control-Allow-Credentials", "true");

        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            chain.doFilter(req, res);
        }
    }
}

Security Headers

Why Security Headers?

Security headers instruct browsers to enable additional protections:

HeaderPrevents
X-Content-Type-OptionsMIME type sniffing
X-Frame-OptionsClickjacking
X-XSS-ProtectionReflected XSS (legacy)
Content-Security-PolicyXSS, data injection
Strict-Transport-SecurityProtocol downgrade attacks
Referrer-PolicyInformation leakage
Permissions-PolicyFeature abuse

Default Security Headers

Spring Security adds these by default:

Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

Custom Header Configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                // Content-Type sniffing protection
                .contentTypeOptions(Customizer.withDefaults())

                // Clickjacking protection
                .frameOptions(frame -> frame.sameOrigin())  // or .deny()

                // XSS Protection (modern browsers ignore this)
                .xssProtection(xss -> xss.disable())

                // HSTS (HTTPS only)
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000)  // 1 year
                    .preload(true)
                )

                // Referrer Policy
                .referrerPolicy(referrer -> referrer
                    .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
                )

                // Permissions Policy (formerly Feature-Policy)
                .permissionsPolicy(permissions -> permissions
                    .policy("geolocation=(), microphone=(), camera=()")
                )

                // Content Security Policy
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives(
                        "default-src 'self'; " +
                        "script-src 'self' 'unsafe-inline' https://cdn.example.com; " +
                        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
                        "img-src 'self' data: https:; " +
                        "font-src 'self' https://fonts.gstatic.com; " +
                        "connect-src 'self' https://api.example.com; " +
                        "frame-ancestors 'none'; " +
                        "form-action 'self'"
                    )
                )
            );

        return http.build();
    }
}

Content Security Policy (CSP) Deep Dive

CSP prevents XSS by controlling resource loading:

@Configuration
public class CspConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives(buildCspPolicy())
                )
            );

        return http.build();
    }

    private String buildCspPolicy() {
        return String.join("; ",
            "default-src 'self'",

            // Scripts
            "script-src 'self' 'nonce-{RANDOM}' https://cdn.jsdelivr.net",

            // Styles
            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",

            // Images
            "img-src 'self' data: https: blob:",

            // Fonts
            "font-src 'self' https://fonts.gstatic.com",

            // Connect (XHR, WebSocket, etc.)
            "connect-src 'self' https://api.example.com wss://ws.example.com",

            // Media
            "media-src 'self'",

            // Objects (Flash, etc.)
            "object-src 'none'",

            // Frames
            "frame-src 'self' https://www.youtube.com",

            // Frame ancestors (who can embed us)
            "frame-ancestors 'self'",

            // Form submissions
            "form-action 'self'",

            // Base URI
            "base-uri 'self'",

            // Upgrade insecure requests
            "upgrade-insecure-requests"
        );
    }
}

CSP with Nonces (Most Secure)

@Component
public class CspNonceFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String nonce = generateNonce();
        request.setAttribute("cspNonce", nonce);

        // Set CSP header with nonce
        String cspPolicy = String.format(
            "default-src 'self'; " +
            "script-src 'self' 'nonce-%s'; " +
            "style-src 'self' 'nonce-%s'",
            nonce, nonce
        );

        response.setHeader("Content-Security-Policy", cspPolicy);

        filterChain.doFilter(request, response);
    }

    private String generateNonce() {
        byte[] nonceBytes = new byte[16];
        new SecureRandom().nextBytes(nonceBytes);
        return Base64.getEncoder().encodeToString(nonceBytes);
    }
}
<!-- Thymeleaf template -->
<script th:attr="nonce=${cspNonce}">
    console.log('This script is allowed');
</script>

Cache Control for Security

@Configuration
public class CacheConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .cacheControl(cache -> cache.disable())  // Disable default
            )
            // Add custom cache headers per endpoint
            .addFilterAfter(new CacheControlFilter(), HeaderWriterFilter.class);

        return http.build();
    }
}

public class CacheControlFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String path = request.getRequestURI();

        if (path.startsWith("/api/")) {
            // No caching for API responses
            response.setHeader("Cache-Control", "no-store");
        } else if (path.startsWith("/static/")) {
            // Cache static resources
            response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
        } else {
            // Default: no caching for dynamic content
            response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        }

        filterChain.doFilter(request, response);
    }
}

Complete Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;

    @Value("${app.cors.allowed-origins}")
    private List<String> allowedOrigins;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF - disabled for stateless JWT API
            .csrf(csrf -> csrf.disable())

            // CORS
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // Security Headers
            .headers(headers -> headers
                .contentTypeOptions(Customizer.withDefaults())
                .frameOptions(frame -> frame.deny())
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000)
                )
                .referrerPolicy(referrer -> referrer
                    .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
                )
                .permissionsPolicy(permissions -> permissions
                    .policy("geolocation=(), camera=(), microphone=()")
                )
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives(
                        "default-src 'self'; " +
                        "script-src 'self'; " +
                        "style-src 'self' 'unsafe-inline'; " +
                        "img-src 'self' data: https:; " +
                        "connect-src 'self'"
                    )
                )
            )

            // Session Management
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // Authorization
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )

            // Exception Handling
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
            )

            // JWT Filter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(allowedOrigins);
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("Authorization"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

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

Testing Security Configuration

@SpringBootTest
@AutoConfigureMockMvc
class SecurityHeadersTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldIncludeSecurityHeaders() throws Exception {
        mockMvc.perform(get("/api/public/test"))
            .andExpect(header().string("X-Content-Type-Options", "nosniff"))
            .andExpect(header().string("X-Frame-Options", "DENY"))
            .andExpect(header().exists("Strict-Transport-Security"))
            .andExpect(header().exists("Content-Security-Policy"));
    }

    @Test
    void shouldAllowCorsFromConfiguredOrigin() throws Exception {
        mockMvc.perform(options("/api/data")
                .header("Origin", "http://localhost:3000")
                .header("Access-Control-Request-Method", "GET"))
            .andExpect(status().isOk())
            .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:3000"));
    }

    @Test
    void shouldBlockCorsFromUnknownOrigin() throws Exception {
        mockMvc.perform(options("/api/data")
                .header("Origin", "https://evil.com")
                .header("Access-Control-Request-Method", "GET"))
            .andExpect(header().doesNotExist("Access-Control-Allow-Origin"));
    }
}

Summary

TopicKey Points
CSRFEnable for session-based auth, disable for stateless JWT
CORSWhitelist specific origins, never use * with credentials
CSPStart strict, relax as needed, use nonces for inline scripts
HSTSAlways enable in production with long max-age
Other HeadersEnable all defaults, customize as needed

Proper security headers provide defense in depth. In the final article, we’ll cover production security best practices.

Series Navigation

  1. Spring Security Architecture
  2. Database Authentication
  3. OAuth2 & Social Login
  4. Method-Level Security
  5. CSRF, CORS & Security Headers (This Article)
  6. Production Security Best Practices

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.