CORS in Spring Boot looks easy right up until the browser starts throwing vague errors and your API “works in Postman” but fails in Chrome.

That’s the normal path.

Spring Boot gives you a few different places to configure CORS, and that flexibility is exactly why teams end up with broken preflight requests, duplicated headers, or insecure wildcard rules in production. I’ve seen all three.

This guide covers how CORS actually works in a Spring Boot app, when to use each configuration style, and the mistakes that tend to waste the most time.

What CORS is actually doing

CORS stands for Cross-Origin Resource Sharing. It’s a browser-enforced policy that controls whether JavaScript running on one origin can read responses from another origin.

An origin is:

  • scheme
  • host
  • port

So these are different origins:

  • https://app.example.com
  • https://api.example.com
  • http://app.example.com
  • https://app.example.com:8443

If your frontend runs on https://app.example.com and calls https://api.example.com, the browser checks the API response for CORS headers.

A very common one is:

Access-Control-Allow-Origin: https://app.example.com

Some public APIs allow any origin:

Access-Control-Allow-Origin: *

A real example from api.github.com:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That second header matters because browsers only expose a limited set of response headers to JavaScript unless you explicitly list them with Access-Control-Expose-Headers.

Simple requests vs preflight requests

Browsers don’t always send CORS requests the same way.

Simple request

A simple cross-origin GET may go straight to the API, and the browser checks the response headers after the fact.

Example:

fetch("https://api.example.com/public")
  .then(r => r.json())
  .then(console.log);

Preflight request

If the browser sees something more sensitive, it sends an OPTIONS request first. That’s the preflight.

Typical triggers:

  • method is PUT, PATCH, or DELETE
  • custom headers like Authorization
  • content type like application/json in some contexts that trigger preflight with fetch and custom headers combinations

Example frontend call:

fetch("https://api.example.com/users/42", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer token"
  },
  body: JSON.stringify({ name: "Alice" })
});

The browser may first send:

OPTIONS /users/42
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

Your Spring Boot app must answer with something like:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type

If that response is wrong, the browser blocks the real request.

Option 1: @CrossOrigin on a controller

This is the fastest way to enable CORS for one controller or one endpoint.

package com.example.demo.web;

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@CrossOrigin(
    origins = "https://app.example.com",
    allowedHeaders = {"Authorization", "Content-Type"},
    methods = {}
)
public class HelloController {

    @GetMapping("/api/hello")
    public String hello() {
        return "hello";
    }
}

You can also apply it to a single method:

@GetMapping("/api/public")
@CrossOrigin(origins = "*")
public String publicData() {
    return "public";
}

When I use @CrossOrigin

  • small apps
  • demos
  • one-off endpoints
  • temporary debugging

When I avoid it

  • larger APIs
  • multiple environments
  • apps using Spring Security
  • anything where rules should be centralized

Scattering CORS rules across controllers gets messy fast.

Option 2: Global CORS with WebMvcConfigurer

This is usually the cleanest choice for a regular Spring MVC API.

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("Authorization", "Content-Type")
                .exposedHeaders("ETag", "Link", "X-RateLimit-Remaining")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

What each piece does:

  • addMapping("/api/**") applies CORS only to those routes
  • allowedOrigins(...) lists trusted origins
  • allowedMethods(...) controls what browsers may send cross-origin
  • allowedHeaders(...) controls request headers allowed in preflight
  • exposedHeaders(...) lets frontend JavaScript read those response headers
  • allowCredentials(true) allows cookies or HTTP auth
  • maxAge(3600) caches preflight results for an hour

Big rule: don’t mix allowCredentials(true) with *

This is one of the most common CORS mistakes.

Bad:

.allowedOrigins("*")
.allowCredentials(true)

Browsers reject that combination. If credentials are allowed, you must return a specific origin, not a wildcard.

If you need flexible matching for subdomains, use origin patterns.

Option 3: allowedOriginPatterns for subdomains

For multi-tenant or environment-based setups, exact origins can be too rigid.

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOriginPatterns("https://*.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

This is much better than hardcoding ten different subdomains.

Still, be careful. https://*.example.com is reasonable. * is not.

Spring Security changes the story

If your app uses Spring Security, CORS must be wired into the security filter chain too. Otherwise preflight OPTIONS requests often get blocked before MVC CORS config even runs.

This is the version people forget.

package com.example.demo.config;

import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .cors(Customizer.withDefaults())
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setExposedHeaders(List.of("ETag", "Link", "X-RateLimit-Remaining"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

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

If you use Spring Security, I prefer this centralized approach over splitting config between MVC and security. Fewer surprises.

Exposing headers to frontend code

A response header being present does not mean browser JavaScript can read it.

For example:

config.setExposedHeaders(List.of("ETag", "Link", "X-RateLimit-Remaining"));

Then frontend code can do:

const response = await fetch("https://api.example.com/repos");
console.log(response.headers.get("ETag"));
console.log(response.headers.get("X-RateLimit-Remaining"));

Without Access-Control-Expose-Headers, those values may be invisible to browser code even though DevTools shows them.

GitHub’s API is a good real-world example here. It exposes many operational headers:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That’s a solid pattern for APIs where clients need pagination, caching, or rate-limit metadata.

Development setup for localhost

A very normal setup is:

  • frontend: http://localhost:5173
  • backend: http://localhost:8080

That is cross-origin because the ports differ.

Typical dev config:

config.setAllowedOrigins(List.of(
    "http://localhost:3000",
    "http://localhost:5173"
));

I usually keep localhost origins explicit instead of using wildcards. It prevents lazy config from leaking into production.

Common CORS bugs in Spring Boot

1. Preflight gets 401 or 403

Usually Spring Security is blocking OPTIONS.

Fix: enable http.cors() and provide a CorsConfigurationSource.

2. You configured CORS twice

Example: @CrossOrigin plus WebMvcConfigurer plus security CORS config.

That can create conflicting behavior and painful debugging. Pick one centralized strategy when possible.

3. Wildcard with credentials

Again, this fails in browsers:

config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true);

Use explicit origins or allowedOriginPatterns.

4. Missing allowed headers

If the browser sends:

Access-Control-Request-Headers: authorization, content-type

and your server doesn’t allow them, preflight fails.

5. CORS confused with authentication

CORS does not protect your API from server-to-server abuse. It only controls what browsers let frontend JavaScript read. Curl, mobile apps, and backend services don’t care about CORS.

Treat CORS as a browser policy, not an auth mechanism.

Testing CORS from the command line

You can simulate a preflight request with curl:

curl -i -X OPTIONS http://localhost:8080/api/users/42 \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type"

You want to see headers like:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type

And for a real request:

curl -i http://localhost:8080/api/hello \
  -H "Origin: https://app.example.com"

If CORS is configured correctly, the response should include:

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

A secure default I actually recommend

For most production Spring Boot APIs:

  • scope CORS to /api/**
  • allow only known frontend origins
  • allow only required methods
  • allow only required headers
  • expose only headers clients need
  • enable credentials only when necessary
  • configure CORS in Spring Security if security is enabled

Example:

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of(
        "https://app.example.com",
        "https://admin.example.com"
    ));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setExposedHeaders(List.of("ETag", "Link"));
    config.setAllowCredentials(true);
    config.setMaxAge(1800L);

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

That’s boring, which is exactly what you want from CORS.

For the official Spring reference, check the Spring Framework and Spring Security documentation:

If you’re also tightening other browser-side protections, headers like CSP, HSTS, and friends matter too. For CSP specifically, I like https://csp-guide.com as a practical reference.