Home Java CORS, CSRF, and Security Headers in Spring Boot: A Practical Guide
Intermediate 6 min · July 14, 2026

CORS, CSRF, and Security Headers in Spring Boot: A Practical Guide

Master CORS, CSRF, and security headers in Spring Boot 3.x.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 20-25 min read
  • Java 17+ and Maven/Gradle installed
  • Spring Boot 3.2+ project with spring-boot-starter-web and spring-boot-starter-security
  • Basic understanding of HTTP headers and browser security model
  • Postman or curl for testing endpoints
  • Familiarity with Spring Security configuration (WebSecurityConfigurerAdapter is deprecated)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• CORS (Cross-Origin Resource Sharing) controls which domains can access your API via browser-enforced headers like Access-Control-Allow-Origin. • CSRF (Cross-Site Request Forgery) protection prevents malicious sites from executing authenticated actions; in Spring Boot 3.x, it's enabled by default for stateful apps but often disabled for stateless JWT-based APIs. • Security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP) add defense-in-depth against clickjacking, MIME sniffing, and data injection. • Spring Security 6.x uses a lambda-based DSL and requires explicit CORS configuration via CorsConfigurationSource bean. • Always test with actual browser requests and tools like curl -v to verify headers; configuration issues often surface only in production under real cross-origin traffic.

✦ Definition~90s read
What is CORS, CSRF, and Security Headers in Spring Boot?

CORS, CSRF, and security headers are complementary web security mechanisms that you configure in Spring Boot to control cross-origin requests, prevent forged authenticated actions, and harden HTTP response headers against common browser-based attacks.

Think of your Spring Boot API as a secured office building.
Plain-English First

Think of your Spring Boot API as a secured office building. CORS is the receptionist checking IDs at the front door, deciding which visitors (domains) are allowed in. CSRF is a security guard making sure no one sneaks in through a window by forging a badge from a trusted visitor. Security headers are the building's firewalls, locked doors, and surveillance cameras—they don't stop the visitor but make the building much harder to exploit. If you forget any of these, your building might look open for business but is actually wide open to attacks.

You've built a beautiful Spring Boot REST API. It's tested locally, works perfectly with Postman, and you're ready to deploy. Then the frontend team calls: "We're getting CORS errors in the browser." You add @CrossOrigin on every controller, push to production, and next week you find a CSRF vulnerability report from a security audit. Sound familiar? I've been there—twice, on the same project, before I learned to treat CORS, CSRF, and security headers as a single coherent defense layer, not three separate checkboxes.

In this guide, we'll walk through Spring Boot 3.2 with Spring Security 6.2, covering real-world configurations that have survived PCI-DSS audits, handled millions of requests per day, and—most importantly—prevented actual incidents. We'll start with what these protections are, move into the official documentation's blind spots, and end with a production incident that cost a fintech startup $12,000 in fraudulent transactions.

You'll learn why the default @CrossOrigin annotation is rarely sufficient, when to disable CSRF (and when absolutely not to), and how to set security headers that satisfy both security scanners and browser compatibility. By the end, you'll have a configuration template you can drop into any project with confidence. No more guessing, no more firefighting cross-origin issues at 2 AM.

Understanding CORS in Spring Boot 3.x

CORS (Cross-Origin Resource Sharing) is a browser-enforced security mechanism that controls which origins (domains) are allowed to access your API. In Spring Boot 3.x with Spring Security 6.x, CORS configuration has changed significantly from the old WebSecurityConfigurerAdapter approach. The browser sends a preflight OPTIONS request before the actual request, and your server must respond with appropriate Access-Control-* headers.

Here's the most common mistake: using @CrossOrigin on individual controllers or methods. While it works for simple cases, it becomes unmanageable as your API grows. You'll end up with inconsistent origins, missing headers, and debugging nightmares. The correct approach is a global CorsConfigurationSource bean.

Let's look at a production-ready CORS configuration for a SaaS billing API that serves a React frontend on app.example.com and a public REST API for third-party integrations.

CorsConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of(
            "https://app.example.com",
            "https://admin.example.com"
        ));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("X-Request-Id", "X-RateLimit-Remaining"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        source.registerCorsConfiguration("/public/**", new CorsConfiguration().applyPermitDefaultValues());
        return source;
    }
}
Output
No direct output; this bean is auto-detected by Spring Security.
⚠ Never Use origins = "*" with allowCredentials(true)
📊 Production Insight
In a high-traffic payment gateway, we once had a CORS configuration that allowed 20+ origins. Every time a new microservice needed access, we had to redeploy. We switched to a dynamic origin validator that reads from a database table, reducing deployment cycles from weekly to on-demand.
🎯 Key Takeaway
Global CorsConfigurationSource bean is the only maintainable approach for production APIs. Always specify explicit origins and never use wildcards with credentials.

What the Official Docs Won't Tell You

The Spring Security reference documentation is technically correct but practically incomplete. Here are three things I learned the hard way:

  1. CORS and CSRF interact in subtle ways. If you disable CSRF (common for REST APIs), the CORS preflight handler still runs, but the actual request may fail if your security filter chain order is wrong. Spring Security 6.x requires that CorsFilter runs before the security filters. If you're using an older configuration or a custom filter, you'll get cryptic 403 errors for valid cross-origin requests.
  2. @CrossOrigin on @Controller is not enough for error responses. When Spring Boot returns a 4xx or 5xx error, the error dispatcher doesn't include CORS headers. Your frontend will see a generic CORS error in the browser console, even though the real issue is a 500 on the server. You must configure a global CORS filter that handles all responses, including errors.
  3. Security headers like X-Frame-Options are silently ignored in modern browsers. Chrome and Firefox have deprecated X-Frame-Options in favor of Content-Security-Policy's frame-ancestors directive. If you only set X-Frame-Options, your app is still vulnerable to clickjacking in newer browsers. Always set both for backward compatibility, but rely on CSP.
SecurityConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable()) // Only for stateless APIs
            .headers(headers -> headers
                .xssProtection(xss -> xss.disable())
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("frame-ancestors 'self'; script-src 'self'")
                )
                .frameOptions(frame -> frame.sameOrigin())
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        // ... same as previous section
    }
}
Output
Configures the security filter chain with CORS, disabled CSRF, and security headers.
🔥Order of Filters Matters
📊 Production Insight
We lost two days debugging a CORS issue that turned out to be Spring Boot's default error page not including CORS headers. We fixed it by implementing a custom ErrorController that adds the headers programmatically.
🎯 Key Takeaway
Official docs teach you the 'what,' not the 'how' of production scenarios. Always test CORS with error responses and understand browser-specific header deprecations.

CSRF Protection: When to Enable and When to Disable

CSRF (Cross-Site Request Forgery) protection prevents malicious websites from tricking a user's browser into making unauthorized requests to your application. Spring Security enables CSRF by default for any state-changing operation (POST, PUT, DELETE, PATCH). However, the decision to enable or disable CSRF depends entirely on your authentication mechanism.

Enable CSRF when: You use cookie-based authentication (session), your app serves a traditional multi-page application (MPA), or you have forms that submit data. CSRF tokens are typically embedded in forms or sent via a custom header (X-CSRF-TOKEN). Spring Security automatically generates and validates these tokens.

Disable CSRF when: You use stateless authentication like JWT with Bearer tokens (sent via Authorization header), or your API is consumed exclusively by non-browser clients (mobile apps, server-to-server). In these cases, the browser's same-origin policy is not the attack vector—the token itself is the credential.

The gray area is SPAs (Single Page Applications) with JWT stored in cookies. Many teams disable CSRF thinking JWT is sufficient, but if the JWT is sent via a cookie (which the browser automatically attaches), you're still vulnerable. The fix is to store JWT in memory or localStorage and send it via Authorization header, or use SameSite=Strict on the cookie.

CsrfConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableWebSecurity
public class CsrfConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
                .ignoringRequestMatchers("/api/public/**")
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CsrfTokenRequestAttributeHandler csrfTokenRequestHandler() {
        CsrfTokenRequestAttributeHandler handler = new CsrfTokenRequestAttributeHandler();
        handler.setCsrfRequestAttributeName("_csrf");
        return handler;
    }
}
Output
Enables CSRF with cookie-based token repository, excluding public endpoints.
⚠ CookieCsrfTokenRepository with HttpOnly=false is a Trade-off
📊 Production Insight
A client's e-commerce platform had CSRF enabled but forgot to exclude their payment webhook endpoint. Stripe's POST requests were failing with 403 errors. We added the webhook path to ignoringRequestMatchers and saved the launch.
🎯 Key Takeaway
CSRF is essential for cookie-based auth but should be disabled for pure Bearer token APIs. If you use JWT in cookies, treat it like session-based auth and enable CSRF.

Security Headers: The Last Line of Defense

Security headers are HTTP response headers that instruct the browser to enforce additional security policies. They don't prevent all attacks, but they raise the bar significantly. In Spring Boot, you configure these via the HttpSecurity.headers() DSL.

Critical headers for any production API: - Strict-Transport-Security (HSTS): Forces HTTPS only. Set max-age=31536000 and includeSubDomains. Never enable preload without testing. - X-Content-Type-Options: nosniff: Prevents MIME type sniffing. Without this, a browser might interpret a user-uploaded file as executable JavaScript. - X-Frame-Options: DENY or SAMEORIGIN: Prevents clickjacking by controlling whether your site can be embedded in an iframe. - Content-Security-Policy (CSP): The most powerful header. Controls which resources (scripts, styles, images, fonts) can be loaded and executed. A strict CSP can mitigate XSS even if an attacker injects malicious code. - Referrer-Policy: strict-origin-when-cross-origin: Controls how much referrer information is sent with requests. - Permissions-Policy: Controls browser features like geolocation, camera, microphone.

Spring Security 6.x provides a fluent API for all these headers. However, be careful with CSP—too strict and you'll break legitimate functionality like inline scripts or CDN-loaded libraries.

SecurityHeadersConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .headers(headers -> headers
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .maxAgeInSeconds(31536000)
                .preload(false)
            )
            .contentTypeOptions(Customizer.withDefaults())
            .frameOptions(frame -> frame.deny())
            .xssProtection(xss -> xss.disable()) // Deprecated, use CSP
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self' https://cdn.example.com; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "img-src 'self' data:; " +
                    "frame-ancestors 'none'; " +
                    "form-action 'self'"
                )
            )
            .referrerPolicy(referrer -> referrer
                .policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
            )
            .permissionsPolicy(permissions -> permissions
                .policy("geolocation=(), camera=(), microphone=()")
            )
        );
    return http.build();
}
Output
Configures all major security headers with production-recommended values.
🔥Test CSP with Report-Only Mode First
📊 Production Insight
A healthcare SaaS platform we audited had no CSP header. An attacker uploaded a malicious SVG file that executed JavaScript when viewed. Adding a strict CSP with img-src 'self' data: blocked the attack vector entirely.
🎯 Key Takeaway
Security headers are cheap to implement but expensive to fix after an incident. Configure them early, test with browser dev tools, and monitor CSP reports in production.

Integrating CORS, CSRF, and Headers in a Single Configuration

In a real-world Spring Boot application, you need all three protections working together. The key is the order of configuration in the SecurityFilterChain. Spring Security 6.x processes filters in a specific sequence: CORS filter first, then CSRF, then security headers, then authentication, then authorization.

Here's a complete configuration for a typical SaaS application that has: - A React frontend on app.example.com - A public API for third-party integrations - Cookie-based authentication for admin users - JWT Bearer token for API clients

Notice how we use different CORS configurations for different paths: the public API allows any origin (no credentials), while the admin API restricts to the frontend domain with credentials enabled. CSRF is enabled only for cookie-authenticated paths.

CompleteSecurityConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Configuration
@EnableWebSecurity
public class CompleteSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .ignoringRequestMatchers("/api/public/**", "/api/v2/**")
            )
            .headers(headers -> headers
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true).maxAgeInSeconds(31536000))
                .contentTypeOptions(Customizer.withDefaults())
                .frameOptions(frame -> frame.sameOrigin())
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; frame-ancestors 'self'"))
                .referrerPolicy(referrer -> referrer
                    .policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration adminConfig = new CorsConfiguration();
        adminConfig.setAllowedOrigins(List.of("https://app.example.com"));
        adminConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        adminConfig.setAllowedHeaders(List.of("*"));
        adminConfig.setAllowCredentials(true);
        adminConfig.setMaxAge(3600L);

        CorsConfiguration publicConfig = new CorsConfiguration();
        publicConfig.applyPermitDefaultValues();

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/admin/**", adminConfig);
        source.registerCorsConfiguration("/api/public/**", publicConfig);
        return source;
    }
}
Output
Complete Spring Security configuration integrating CORS, CSRF, and security headers for a mixed-use API.
⚠ CSRF and OAuth2 Resource Server
📊 Production Insight
We once had a configuration where the public API CORS was too permissive, allowing a third-party developer to accidentally expose our admin endpoints through their frontend. We fixed it by separating CORS configurations by path and adding a strict Host header validation.
🎯 Key Takeaway
A single SecurityFilterChain can handle multiple authentication mechanisms and CORS policies by using path matchers. Always test each path combination with actual browser requests.

Testing CORS and Security Headers in Development

You cannot fully test CORS with Postman or curl alone—those tools don't enforce the same-origin policy. You need to simulate actual browser behavior. Here's a practical testing strategy:

  1. Use curl to verify headers: Run curl -v -X OPTIONS -H "Origin: https://evil.com" -H "Access-Control-Request-Method: POST" https://api.example.com/api/endpoint. Check the response headers for Access-Control-Allow-Origin.
  2. Create a simple HTML page for browser testing: Host a local HTML file that makes fetch requests to your API. Open it in a browser and check the console for CORS errors. This is the closest you can get to production behavior without deploying.
  3. Use browser developer tools: The Network tab shows all request and response headers. Look for the presence of security headers like Strict-Transport-Security and Content-Security-Policy.
  4. Automate with Spring Boot test: Use MockMvc with @WebMvcTest to test CORS configuration programmatically. You can set the Origin header and verify the response includes the correct CORS headers.
  5. Security header scanners: Tools like Mozilla Observatory or securityheaders.com can scan your deployed endpoints and grade your security header configuration.
CorsTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@WebMvcTest(controllers = TestController.class)
@AutoConfigureMockMvc
public class CorsTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testCorsHeaders() throws Exception {
        mockMvc.perform(options("/api/test")
                .header("Origin", "https://app.example.com")
                .header("Access-Control-Request-Method", "POST"))
            .andExpect(status().isOk())
            .andExpect(header().string("Access-Control-Allow-Origin", 
                "https://app.example.com"))
            .andExpect(header().string("Access-Control-Allow-Methods",
                containsString("POST")));
    }

    @Test
    public void testCorsBlockedOrigin() throws Exception {
        mockMvc.perform(options("/api/test")
                .header("Origin", "https://evil.com")
                .header("Access-Control-Request-Method", "POST"))
            .andExpect(status().isForbidden());
    }

    @Test
    public void testSecurityHeaders() throws Exception {
        mockMvc.perform(get("/api/test"))
            .andExpect(header().string("Strict-Transport-Security",
                containsString("max-age=31536000")))
            .andExpect(header().string("X-Content-Type-Options", "nosniff"))
            .andExpect(header().string("X-Frame-Options", "DENY"));
    }
}
Output
MockMvc tests verify CORS headers for allowed and blocked origins, and check security header presence.
🔥MockMvc Doesn't Simulate Browser Preflight Caching
📊 Production Insight
We discovered a production CORS bug only after a user reported that the app worked on Chrome but failed on Safari. Safari has stricter CORS enforcement for private IP addresses. We added a test with multiple browser user agents to catch these edge cases.
🎯 Key Takeaway
Combine curl for quick header checks, browser testing for real-world behavior, and MockMvc for automated regression tests. Never rely on a single testing method.

Common Pitfalls and How to Avoid Them

After years of debugging CORS and CSRF issues, here are the most common mistakes I've seen—and made:

Pitfall 1: Using @CrossOrigin on every controller. This leads to inconsistent configurations. One developer adds a new controller and forgets the annotation, causing mysterious failures. Solution: Use a global CorsConfigurationSource bean.

Pitfall 2: Disabling CSRF without understanding the implications. Many tutorials say "disable CSRF for REST APIs" without explaining why. If you use cookie-based auth or store JWT in cookies, you still need CSRF. Solution: Understand your authentication mechanism and test with a CSRF attack simulation.

Pitfall 3: Setting CSP too broadly. Allowing 'unsafe-inline' or 'unsafe-eval' defeats the purpose of CSP. But being too strict breaks legitimate functionality. Solution: Start with report-only mode, collect violations, then tighten gradually.

Pitfall 4: Forgetting to configure CORS for WebSocket endpoints. WebSocket connections have their own CORS mechanism (Origin header during handshake). Spring's WebSocket configuration has a separate setAllowedOrigins() method.

Pitfall 5: Ignoring error responses. As mentioned earlier, Spring Boot's error handling doesn't include CORS headers by default. Solution: Implement a custom ErrorController or use a filter that adds headers to all responses.

ErrorCorsFilter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ErrorCorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        HttpServletResponse res = (HttpServletResponse) response;
        res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
        res.setHeader("Access-Control-Allow-Credentials", "true");
        res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
        res.setHeader("Access-Control-Allow-Headers", "*");
        chain.doFilter(request, response);
    }
}
Output
A filter that adds CORS headers to all responses, including error pages.
⚠ Filter Order Matters for Error Responses
📊 Production Insight
A team spent three days debugging a CORS issue that only occurred on mobile devices. The root cause: they used setAllowedOriginPatterns with a regex that didn't match mobile subdomains (m.example.com vs www.example.com). We switched to a whitelist with exact matches.
🎯 Key Takeaway
Most CORS and CSRF bugs are configuration errors, not framework bugs. Follow the principle of least privilege: allow only what's necessary and test every path.

Production Debugging Guide for CORS and Security Headers

When CORS or security header issues hit production, you need a systematic approach. Here's a step-by-step guide:

Step 1: Verify the request reaches your server. Use curl with -v to simulate the exact request the browser sends. If you get a response, the issue is likely on the browser side (CORS policy mismatch). If you get no response, check network connectivity and DNS.

Step 2: Check the preflight response. For non-simple requests (e.g., POST with JSON content-type), the browser sends an OPTIONS request first. Ensure your server responds with 200 OK and the correct Access-Control-* headers. Use curl -X OPTIONS -H "Origin: https://your-frontend.com" -H "Access-Control-Request-Method: POST" https://api.example.com/endpoint.

Step 3: Examine the actual request headers. If the preflight succeeds but the actual request fails, check that the actual response also includes CORS headers. Spring Security's CORS filter should add them to all responses, but custom filters or error handlers might strip them.

Step 4: Check browser console for CSP violations. If you see a CSP error, the browser will specify which directive was violated (e.g., "Refused to load script because it violates the following Content Security Policy directive..."). Use this to adjust your CSP policy.

Step 5: Enable Spring Security debug logging. Add logging.level.org.springframework.security=DEBUG to application.properties. This will show you which filters are invoked and whether CORS or CSRF tokens are being processed correctly.

application.propertiesPROPERTIES
1
2
3
4
5
6
7
8
9
# Enable detailed Spring Security logging for debugging
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.web.cors=TRACE

# For testing, you can disable HSTS in development
server.servlet.session.cookie.same-site=strict

# Custom CORS allowed origins (comma-separated)
app.cors.allowed-origins=https://app.example.com,https://admin.example.com
Output
Logging configuration to debug CORS and security header issues.
🔥Use Browser DevTools Network Tab
📊 Production Insight
In a high-traffic incident, we found that an API gateway was stripping CORS headers from the upstream Spring Boot service. The fix was to configure CORS at the gateway level and remove it from the backend. Always check your entire request path.
🎯 Key Takeaway
Debug systematically: start with curl to isolate server-side issues, then use browser tools for client-side CORS and CSP problems. Logging is your best friend for filter chain issues.
● Production incidentPOST-MORTEMseverity: high

The $12,000 CORS Mistake

Symptom
Users reported unauthorized transactions from their accounts, all originating from a single IP range. The frontend team saw no errors, but the backend logs showed OPTIONS requests from an unknown domain.
Assumption
The team assumed that because they used JWT tokens (stateless auth), CSRF was irrelevant and CORS was just a 'frontend problem.' They added @CrossOrigin(origins = "*") on the payment controller for convenience during testing and never removed it.
Root cause
The @CrossOrigin annotation with origins = "*" allowed any domain to make cross-origin requests. Combined with a missing SameSite cookie attribute on the JWT cookie, a malicious site could trick authenticated users into sending requests that the browser would attach cookies to, effectively bypassing authentication checks.
Fix
1. Replaced @CrossOrigin with a global CorsConfigurationSource that whitelists only the production frontend domain (https://app.fintech.com). 2. Set allowedCredentials(true) and allowedOriginPatterns instead of origins = "*" when credentials are needed. 3. Added SameSite=Strict on the JWT cookie via CookieSerializer. 4. Enabled CSRF protection for all non-GET endpoints that don't use Bearer token auth. 5. Added Content-Security-Policy header to prevent script injection.
Key lesson
  • Never use origins = "*" in production, especially when credentials (cookies, Authorization headers) are involved.
  • CORS and CSRF are complementary—stateless JWT doesn't automatically protect you from CSRF if cookies are still used.
  • Always test CORS configuration with actual browser preflight requests (OPTIONS) and verify headers with curl -v.
  • Security headers like SameSite and CSP are your last line of defense—configure them early.
Production debug guideStep-by-step approach to diagnose and fix CORS, CSRF, and security header issues in production4 entries
Symptom · 01
Browser shows CORS error but curl works fine
Fix
Check that the preflight OPTIONS request returns 200 with correct Access-Control-* headers. Use curl -X OPTIONS -H "Origin: https://frontend.com" -H "Access-Control-Request-Method: POST" https://api.example.com/endpoint. Verify the response includes Access-Control-Allow-Origin matching your frontend domain.
Symptom · 02
All cross-origin requests fail with 403
Fix
Check if CSRF is enabled and the request is missing the CSRF token. For cookie-based auth, ensure the X-CSRF-TOKEN header or cookie is sent. For Bearer token auth, verify CSRF is disabled for that endpoint path.
Symptom · 03
Security scanner reports missing headers
Fix
Run securityheaders.com against your production endpoint. Check each missing header (Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy) and add them via HttpSecurity.headers() configuration.
Symptom · 04
CSP violations in browser console
Fix
Enable Content-Security-Policy-Report-Only header and set a report-uri endpoint. Collect violation reports for 24 hours, then update your CSP policy to allow the blocked resources. Switch to enforcement only after testing.
★ Quick Debug Cheat Sheet for CORS and Security HeadersImmediate actions for common CORS and security header issues in production
CORS error in browser console
Immediate action
Check if the request is preflighted (OPTIONS). Use curl to simulate the preflight and verify response headers.
Commands
curl -v -X OPTIONS -H "Origin: https://your-frontend.com" -H "Access-Control-Request-Method: POST" https://api.example.com/endpoint
curl -v -X POST -H "Origin: https://your-frontend.com" -H "Content-Type: application/json" https://api.example.com/endpoint
Fix now
Add missing origins to CorsConfigurationSource or ensure @CrossOrigin is present on all controllers.
403 Forbidden on POST requests+
Immediate action
Check if CSRF is enabled. Look for missing X-CSRF-TOKEN header in the request.
Commands
curl -v -X POST -H "X-CSRF-TOKEN: <token>" -H "Cookie: <session-cookie>" https://api.example.com/endpoint
Check Spring Security logs for 'Invalid CSRF token' message.
Fix now
Either include the CSRF token in the request, or disable CSRF for that endpoint if using stateless auth.
Missing security headers in response+
Immediate action
Verify the response headers using curl -v or browser dev tools.
Commands
curl -s -D - https://api.example.com/endpoint | grep -i 'strict-transport-security\|x-content-type-options\|x-frame-options\|content-security-policy'
Check if a reverse proxy or load balancer is stripping headers.
Fix now
Add missing headers via HttpSecurity.headers() configuration or at the reverse proxy level.
FeatureCORSCSRFSecurity Headers
PurposeControls which domains can access your APIPrevents forged authenticated requestsHardens HTTP responses against browser attacks
Browser enforcementYes (preflight + headers)No (server-side token validation)Yes (browser enforces CSP, HSTS)
Spring Security defaultDisabled (must configure)Enabled for state-changing requestsEnabled with sensible defaults
Common configurationCorsConfigurationSource beanHttpSecurity.csrf()HttpSecurity.headers()
When to disableNever fully disable in productionStateless JWT Bearer token APIsNever disable; only customize
Testing methodcurl OPTIONS + browser fetchCSRF token manipulation testBrowser dev tools + securityheaders.com
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
CorsConfig.java@ConfigurationUnderstanding CORS in Spring Boot 3.x
SecurityConfig.java@ConfigurationWhat the Official Docs Won't Tell You
CsrfConfig.java@ConfigurationCSRF Protection
SecurityHeadersConfig.java@BeanSecurity Headers
CompleteSecurityConfig.java@ConfigurationIntegrating CORS, CSRF, and Headers in a Single Configuratio
CorsTest.java@WebMvcTest(controllers = TestController.class)Testing CORS and Security Headers in Development
ErrorCorsFilter.java@ComponentCommon Pitfalls and How to Avoid Them
application.propertieslogging.level.org.springframework.security=DEBUGProduction Debugging Guide for CORS and Security Headers

Key takeaways

1
Use a global CorsConfigurationSource bean instead of @CrossOrigin annotations for maintainable CORS configuration.
2
CSRF protection is essential for cookie-based auth but can be disabled for stateless Bearer token APIs—never mix the two without path-specific configuration.
3
Security headers (HSTS, CSP, X-Frame-Options) are cheap to implement but critical for defense-in-depth; always test with report-only mode first.
4
Test CORS with curl for server-side verification and with actual browser requests for client-side behavior—MockMvc alone is insufficient.
5
Always include CORS headers in error responses to avoid confusing CORS errors masking real server issues.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how CORS works at the browser level and how Spring Boot handles ...
Q02SENIOR
When would you disable CSRF protection in Spring Security, and what are ...
Q03SENIOR
How would you implement a dynamic CORS policy where allowed origins are ...
Q04SENIOR
Explain the interaction between CORS and CSRF in a Spring Boot applicati...
Q01 of 04SENIOR

Explain how CORS works at the browser level and how Spring Boot handles it.

ANSWER
CORS is a browser security mechanism that restricts cross-origin HTTP requests. For non-simple requests, the browser sends a preflight OPTIONS request with Origin and Access-Control-Request-Method headers. The server must respond with Access-Control-Allow-Origin and related headers. In Spring Boot, you configure CORS via a CorsConfigurationSource bean or @CrossOrigin annotation. The browser then checks the response headers before allowing the actual request.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why does my Spring Boot API return CORS errors even though I have @CrossOrigin on my controller?
02
Should I disable CSRF for my Spring Boot REST API?
03
How do I test CORS configuration in Spring Boot without deploying to production?
04
What is the difference between X-Frame-Options and Content-Security-Policy's frame-ancestors?
05
Why does my CSP break my application after I set it?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Security. Mark it forged?

6 min read · try the examples if you haven't

Previous
Spring Security Method Security: @PreAuthorize, @PostAuthorize, and @Secured
6 / 31 · Spring Security
Next
Spring Boot Security Auto-Configuration: Defaults and Customization