Spring BootCORS Configuration
Spring Boot

CORS Configuration

CORS (Cross-Origin Resource Sharing) is a browser security mechanism that blocks JavaScript from making requests to a different origin (domain, port, or protocol) than the page it loaded from. When a React SPA on localhost:3000 calls a Spring Boot API on localhost:8080, the browser enforces CORS. Spring Boot provides three layers for configuring CORS — @CrossOrigin on controllers, global WebMvcConfigurer, and Spring Security's cors() DSL.

How CORS Works

The browser enforces CORS — the server has no way to prevent a request from being made, but it controls whether the browser allows JavaScript to read the response. For simple requests (GET, POST with simple headers), the browser adds an Origin header and checks the response's Access-Control-Allow-Origin. For complex requests (custom headers, non-simple methods), the browser sends a preflight OPTIONS request first.
Shell
# ── Simple CORS request flow: ────────────────────────────────────────
# Browser (localhost:3000) → API (localhost:8080)
#
# Request:
# GET /api/users HTTP/1.1
# Origin: http://localhost:3000
#
# Response (CORS allowed):
# Access-Control-Allow-Origin: http://localhost:3000
# → Browser allows JavaScript to read the response ✓
#
# Response (CORS blocked):
# (no Access-Control-Allow-Origin header)
# → Browser blocks JavaScript from reading response
# → Console: "CORS policy: No 'Access-Control-Allow-Origin' header"

# ── Preflight request flow (for complex requests): ────────────────────
# Before POST /api/users with Content-Type: application/json,
# the browser sends a preflight:
#
# OPTIONS /api/users HTTP/1.1
# Origin: http://localhost:3000
# Access-Control-Request-Method: POST
# Access-Control-Request-Headers: Content-Type, Authorization
#
# Response (preflight accepted):
# Access-Control-Allow-Origin:  http://localhost:3000
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type, Authorization
# Access-Control-Max-Age:       3600
# → Browser sends the real POST request ✓

# ── Key CORS response headers: ────────────────────────────────────────
# Access-Control-Allow-Origin:      which origins are permitted
# Access-Control-Allow-Methods:     which HTTP methods are permitted
# Access-Control-Allow-Headers:     which request headers are permitted
# Access-Control-Allow-Credentials: whether cookies/auth are permitted
# Access-Control-Max-Age:           how long to cache the preflight (seconds)
# Access-Control-Expose-Headers:    which response headers JS can read

@CrossOrigin — Controller-Level CORS

@CrossOrigin on a controller class or method configures CORS for those specific endpoints. It is the simplest approach for fine-grained per-endpoint control.
Java
// ── @CrossOrigin on the controller class — applies to all methods: ────
@RestController
@RequestMapping("/api/users")
@CrossOrigin(
    origins = {"http://localhost:3000", "https://myapp.com"},
    methods = {RequestMethod.GET, RequestMethod.POST,
               RequestMethod.PUT, RequestMethod.DELETE},
    allowedHeaders = {"Content-Type", "Authorization"},
    allowCredentials = "true",
    maxAge = 3600
)
public class UserController {

    @GetMapping
    public List<UserResponse> findAll() { ... }

    @PostMapping
    public ResponseEntity<UserResponse> create(...) { ... }
}

// ── @CrossOrigin on a specific method — overrides class-level: ────────
@RestController
@RequestMapping("/api/products")
@CrossOrigin(origins = "https://myapp.com")   // default for all methods
public class ProductController {

    @GetMapping
    public List<ProductResponse> findAll() { ... }

    // This endpoint also allows the dev origin:
    @PostMapping
    @CrossOrigin(origins = {"https://myapp.com", "http://localhost:3000"})
    public ResponseEntity<ProductResponse> create(...) { ... }
}

// ── @CrossOrigin defaults (when no attributes specified): ─────────────
// origins:          all origins ("*") — NOT credentials-compatible
// methods:          GET, POST, HEAD (plus the method's HTTP method)
// allowedHeaders:   all headers
// exposedHeaders:   none
// allowCredentials: not set (browser default applies)
// maxAge:           1800 seconds (30 minutes)

// ── WARNING: credentials + wildcard origin is invalid: ────────────────
@CrossOrigin(origins = "*", allowCredentials = "true")
// This will NOT work — browsers reject Access-Control-Allow-Origin: *
// combined with Access-Control-Allow-Credentials: true.
// Always specify exact origins when using credentials.

Global CORS — WebMvcConfigurer

Global CORS configuration in WebMvcConfigurer.addCorsMappings() applies to all controllers without annotating each one. This is the recommended approach for consistent API-wide CORS policy.
Java
@Configuration
public class WebConfig implements WebMvcConfigurer {

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

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")       // apply to all /api/** paths
            .allowedOrigins(
                "http://localhost:3000",     // React dev server
                "http://localhost:4200",     // Angular dev server
                "https://myapp.com",         // production frontend
                "https://admin.myapp.com"    // admin frontend
            )
            .allowedMethods(
                "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
            )
            .allowedHeaders(
                "Content-Type",
                "Authorization",
                "X-Requested-With",
                "Accept",
                "Origin",
                "X-CSRF-TOKEN"
            )
            .exposedHeaders(
                "Location",              // expose for 201 Created responses
                "X-Total-Count"          // expose for pagination headers
            )
            .allowCredentials(true)      // allow cookies and auth headers
            .maxAge(3600);               // cache preflight for 1 hour

        // Different rules for public endpoints:
        registry.addMapping("/api/public/**")
            .allowedOrigins("*")         // any origin for public endpoints
            .allowedMethods("GET")
            .allowCredentials(false)     // no credentials for public
            .maxAge(86400);              // cache preflight for 24 hours
    }
}

# ── application.yml — externalise allowed origins: ────────────────────
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:4200
      - https://myapp.com

CORS with Spring Security

When Spring Security is active, it processes requests before Spring MVC. CORS preflight OPTIONS requests must pass through Spring Security without authentication checks, and the CORS configuration must be registered with Spring Security — not just with Spring MVC.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // ── Register CORS with Spring Security: ──────────────────
            // Without this, Spring Security blocks preflight OPTIONS requests
            // before they reach the CORS filter.
            .cors(cors -> cors
                .configurationSource(corsConfigurationSource())
            )
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // preflights
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }

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

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

        // Or use patterns for wildcard subdomain matching:
        config.setAllowedOriginPatterns(List.of(
            "https://*.myapp.com",       // any subdomain of myapp.com
            "http://localhost:[*]"       // any localhost port
        ));

        config.setAllowedMethods(List.of(
            "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
        ));

        config.setAllowedHeaders(List.of(
            "Authorization", "Content-Type", "X-Requested-With",
            "Accept", "Origin", "X-XSRF-TOKEN"
        ));

        config.setExposedHeaders(List.of("Location", "X-Total-Count"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

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

        // Permissive config for public endpoints:
        CorsConfiguration publicConfig = new CorsConfiguration();
        publicConfig.setAllowedOrigins(List.of("*"));
        publicConfig.setAllowedMethods(List.of("GET"));
        source.registerCorsConfiguration("/api/public/**", publicConfig);

        return source;
    }
}

CORS for Specific Environments

CORS rules differ between development (permissive, localhost), staging (restricted to staging domains), and production (production domains only). Externalise allowed origins via @ConfigurationProperties and override per-profile.
yaml
// ── @ConfigurationProperties for CORS: ───────────────────────────────
@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(
    List<String> allowedOrigins,
    List<String> allowedOriginPatterns,
    List<String> allowedMethods,
    List<String> allowedHeaders,
    boolean allowCredentials,
    long maxAge
) { }

// ── application.yml — base config: ───────────────────────────────────
app:
  cors:
    allowed-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
    allowed-headers: Authorization, Content-Type, X-Requested-With, Accept
    allow-credentials: true
    max-age: 3600

// ── application-dev.yml — permissive dev config: ─────────────────────
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:4200
      - http://localhost:5173    # Vite dev server

// ── application-prod.yml — strict production config: ─────────────────
app:
  cors:
    allowed-origins:
      - https://myapp.com
      - https://admin.myapp.com
    allowed-origin-patterns:
      - https://*.myapp.com

// ── Use in configuration: ─────────────────────────────────────────────
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(CorsProperties.class)
public class CorsConfig {

    private final CorsProperties corsProperties;

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(corsProperties.allowedOrigins());
        if (corsProperties.allowedOriginPatterns() != null)
            config.setAllowedOriginPatterns(
                corsProperties.allowedOriginPatterns());
        config.setAllowedMethods(corsProperties.allowedMethods());
        config.setAllowedHeaders(corsProperties.allowedHeaders());
        config.setAllowCredentials(corsProperties.allowCredentials());
        config.setMaxAge(corsProperties.maxAge());

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