Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 057 / 100

CORS in Spring Boot, configured correctly for production

Most CORS errors are configuration mistakes.

Expona Admin6 min read

CORS in Spring Boot, configured correctly for production

We've all seen it: a frontend deployed to one domain making requests to an API on another, only to hit a wall of CORS errors in the browser console. Most of the time, these aren't real security problems—they're configuration mistakes. This guide walks you through CORS in Spring Boot, configured correctly for production, covering the decisions and code that keep your cross-origin requests working while maintaining proper security boundaries.

Why CORS configuration matters in production

CORS (Cross-Origin Resource Sharing) is a browser security feature that prevents unauthorized cross-domain requests. When your frontend at app.example.com tries to call api.example.com, the browser enforces CORS rules. Misconfiguring CORS either blocks legitimate traffic or opens security holes. In production, a single @CrossOrigin(origins = "*") annotation on your controller is often the difference between a working feature and one that's compromised or blocked for authenticated users.

The problem compounds when teams configure CORS only at the Spring MVC level but forget the security chain, or when they allow wildcard origins with credentials. Let's fix these mistakes systematically.

Configure CORS in the security chain, not only in MVC

Many teams decorate their controllers with @CrossOrigin and assume CORS is handled. In reality, Spring Security sits upstream of your controllers. If a preflight request or a credentialed request hits an authentication filter before it reaches your endpoint, CORS headers never get sent back, and the browser blocks the response.

The correct place to configure CORS for production is in your SecurityFilterChain. This ensures CORS headers are added early, before any security filters evaluate the request.

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 org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .csrf(csrf -> csrf.disable()); // example only; adjust for your needs

        return http.build();
    }

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

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

This configuration applies CORS globally to all routes. Spring Security processes it before authentication filters run, so CORS headers are always present. Notice we define AllowedOrigins as a concrete list, not a wildcard.

If you need different CORS rules for different endpoints, map multiple patterns:

java
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

CorsConfiguration publicApi = new CorsConfiguration();
publicApi.setAllowedOrigins(List.of("*"));
publicApi.setAllowedMethods(List.of("GET", "OPTIONS"));
source.registerCorsConfiguration("/api/public/**", publicApi);

CorsConfiguration privateApi = new CorsConfiguration();
privateApi.setAllowedOrigins(List.of("https://app.example.com"));
privateApi.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
privateApi.setAllowCredentials(true);
source.registerCorsConfiguration("/api/private/**", privateApi);

return source;

List exact origins — never * with credentials

One of the most dangerous mistakes is using setAllowedOrigins(List.of("*")) when setAllowCredentials(true). Browsers explicitly forbid this combination. If you set wildcard origins and credentials to true, your configuration will be silently rejected, and your app won't work at all.

The why: a wildcard origin combined with credentials would allow any website to make authenticated requests on behalf of your user. If a malicious site could trick a user into visiting it while logged into your app, that site could perform actions without the user's knowledge.

In production, always enumerate your origins:

java
CorsConfiguration config = new CorsConfiguration();

// ✗ DO NOT DO THIS
// config.setAllowedOrigins(List.of("*"));
// config.setAllowCredentials(true);

// ✓ DO THIS
config.setAllowedOrigins(List.of(
    "https://app.example.com",
    "https://admin.example.com",
    "https://app-staging.example.com" // staging if needed
));
config.setAllowCredentials(true);

Load origins from environment variables or a config server, not hardcoded in production code:

java
@Configuration
public class CorsConfig {

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

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(allowedOrigins); // e.g., ["https://app.example.com"]
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowCredentials(true);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

And in your application-prod.yml:

java
app:
  cors:
    allowed-origins: "https://app.example.com,https://admin.example.com"

Allow the headers your frontend sends

Your frontend might send custom headers like X-API-Version, X-Idempotency-Key, or Authorization. If your CORS config doesn't allow them, the browser will reject the request at the preflight stage.

The safest approach for a backend you control is to allow all headers:

java
config.setAllowedHeaders(List.of("*"));

If you need to be stricter (e.g., for public APIs), enumerate specific headers:

java
config.setAllowedHeaders(List.of(
    "Content-Type",
    "Authorization",
    "X-Requested-With",
    "X-API-Version",
    "X-Idempotency-Key"
));

The same applies to response headers that your frontend might need to read:

java
config.setExposedHeaders(List.of(
    "X-Total-Count",      // for pagination
    "X-RateLimit-Remaining",
    "Location"            // for redirect URLs
));

If you forget to expose a header, the browser will receive it but your JavaScript won't be able to access it via response.headers.get().

Preflight requests must not require auth

Before the browser sends a "real" request that modifies data (POST, PUT, DELETE), it sends an OPTIONS preflight request to check if CORS allows it. This preflight request has no credentials and must not hit an authentication guard.

If your security config requires authentication for all requests, preflight OPTIONS calls will fail, and your frontend won't even attempt the actual request.

Configure your security chain to allow OPTIONS requests without authentication:

java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        .authorizeHttpRequests(authz -> authz
            .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // ← This is key
            .requestMatchers("/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .sessionManagement(session -> session.sessionFixationProtection(SessionFixationProtectionStrategy.MITIGATE))
        .csrf(csrf -> csrf.disable());

    return http.build();
}

With this rule, any OPTIONS request skips authentication. Spring handles the CORS response automatically. The actual POST, PUT, or DELETE that follows will still require auth if your endpoints demand it.

If you're using JWT tokens, your preflight request now works because the browser doesn't send the Authorization header on OPTIONS. Once the browser confirms CORS is okay, it sends the real request with your JWT, and authentication succeeds.

Common mistakes

Mistake 1: CORS only in controllers, not in security config. Your CORS headers never reach the browser if a security filter rejects the request first. Always configure CORS in the SecurityFilterChain.

Mistake 2: Using wildcard origins with credentials. Browsers silently reject this combination. Enumerate your origins explicitly in production.

Mistake 3: Forgetting OPTIONS in the security chain. If your auth guards intercept OPTIONS requests, preflight fails. Use .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() to bypass auth for preflight.

Mistake 4: Hardcoding origins in code. Use environment variables so you can change origins between staging and production without recompiling.

Mistake 5: Not allowing custom headers. If your frontend sends a header you didn't explicitly allow, CORS fails silently. Use config.setAllowedHeaders(List.of("*")) or enumerate all headers you use.

Key takeaways

  • Configure CORS in SecurityFilterChain via CorsConfigurationSource, not just in MVC annotations.
  • List exact origins in AllowedOrigins; never combine wildcard origins with setAllowCredentials(true).
  • Allow all headers with setAllowedHeaders(List.of("*")) for internal APIs, or enumerate specific headers for public APIs.
  • Expose response headers your frontend needs to read via setExposedHeaders().
  • Permit OPTIONS requests without authentication: .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll().
  • Load allowed origins from environment variables or a config server, not hardcoded strings.
  • Set setMaxAge() to cache preflight responses and reduce latency (3600 seconds is common).
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles