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.
Moshiour Rahman
Advertisement
Understanding Web Security Threats
Before diving into configuration, understand what these protections prevent:
| Protection | Prevents | Attack Type |
|---|---|---|
| CSRF | Cross-Site Request Forgery | Malicious site tricks user’s browser into making requests |
| CORS | Unauthorized cross-origin requests | Scripts from other origins accessing your API |
| Security Headers | Various attacks | XSS, 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:
- Generating a unique token per session
- Requiring that token on state-changing requests (POST, PUT, DELETE)
- 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();
}
}
| Scenario | CSRF | Reason |
|---|---|---|
| Traditional web app with sessions | Enable | Browser sends cookies automatically |
| REST API with JWT in header | Disable | Token not sent automatically |
| REST API with cookie-based JWT | Enable | Cookie sent automatically |
| Mixed (web + API) | Selective | Disable 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:
| Header | Prevents |
|---|---|
X-Content-Type-Options | MIME type sniffing |
X-Frame-Options | Clickjacking |
X-XSS-Protection | Reflected XSS (legacy) |
Content-Security-Policy | XSS, data injection |
Strict-Transport-Security | Protocol downgrade attacks |
Referrer-Policy | Information leakage |
Permissions-Policy | Feature 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
| Topic | Key Points |
|---|---|
| CSRF | Enable for session-based auth, disable for stateless JWT |
| CORS | Whitelist specific origins, never use * with credentials |
| CSP | Start strict, relax as needed, use nonces for inline scripts |
| HSTS | Always enable in production with long max-age |
| Other Headers | Enable 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
- Spring Security Architecture
- Database Authentication
- OAuth2 & Social Login
- Method-Level Security
- CSRF, CORS & Security Headers (This Article)
- Production Security Best Practices
Advertisement
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
Spring Security Method-Level Authorization: Complete @PreAuthorize Guide
Master method-level security in Spring Boot. Learn @PreAuthorize, @PostAuthorize, SpEL expressions, custom permissions, and domain object security.
Spring BootSpring Security Architecture: Complete Guide to How Security Works
Master Spring Security internals. Learn FilterChain, SecurityContext, Authentication flow, and how Spring Security protects your application.
Spring BootSpring Security OAuth2: Complete Social Login Guide (Google, GitHub)
Implement OAuth2 social login in Spring Boot. Learn Google, GitHub authentication, custom OAuth2 providers, and combining with JWT.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.