CORS
Let a browser front end on another origin call your API by configuring CORS once in Spring Security, with explicit origins rather than wildcards.
http.cors(Customizer.withDefaults()); // uses the CorsConfigurationSource bean
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST"));
config.setAllowCredentials(true);Browsers stop a page on https://app.example.com from reading responses from https://api.example.com unless the API says that origin is allowed. That permission system is CORS (Cross-Origin Resource Sharing). It is enforced by the browser only: curl, mobile apps and other servers ignore it entirely, so CORS is not access control. It just decides which websites may use your API from a visitor's browser.
Preflight requests
For anything beyond a simple GET, such as a POST with JSON, a PUT, or a request with an Authorization header, the browser first sends an OPTIONS preflight asking which methods and headers are allowed. That preflight carries no credentials. If Spring Security sees it before CORS is handled, it answers 401, and the browser reports a CORS error that hides the real cause.
That is why CORS must be handled by Spring Security itself. Call http.cors(Customizer.withDefaults()) and declare a CorsConfigurationSource bean. The CORS filter then runs early in the chain, answers preflights and adds the response headers to real requests. Spring Security also switches CORS on by itself when a UrlBasedCorsConfigurationSource bean exists, but writing cors() out keeps the intent visible in the configuration.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}Settings worth understanding
- Allowed origins are scheme, host and port, with no path and no trailing slash.
http://localhost:5173andhttp://127.0.0.1:5173are different origins. - Credentials. If the front end sends cookies (
credentials: 'include'), you needsetAllowCredentials(true), and then a literal*origin is rejected. Use explicit origins, orsetAllowedOriginPatternsfor something likehttps://*.example.com. - Exposed headers. JavaScript can only read a few response headers by default. Expose
Locationif the client follows it after a201. - Max age lets the browser cache a preflight answer so it is not repeated before every call.
Common mistakes
Allowing every origin with credentials is the dangerous one: any website a logged-in user visits could call your API as them. Keep the list in configuration so each environment allows its own front end, and never reflect the request's Origin header back blindly. Spring MVC's @CrossOrigin and addCorsMappings still exist, but with Spring Security on the classpath keep CORS in one place, the security configuration, so preflights are not rejected before MVC sees them. Actuator endpoints have their own management.endpoints.web.cors.* properties.
Example
import java.time.Duration;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
("app.cors")
record CorsProperties(List<String> allowedOrigins) {
}
(CorsProperties.class)
public class CorsSecurityConfig {
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults()) // picks up the bean below
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
CorsConfigurationSource corsConfigurationSource(CorsProperties props) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(props.allowedOrigins());
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Idempotency-Key"));
config.setExposedHeaders(List.of("Location"));
config.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
When to use it
- Let a React or Vue single-page app on its own domain call a Spring Boot API with bearer tokens.
- Allow the local Vite dev server in development without opening the production API to localhost.
- Serve a cookie-authenticated dashboard on several company subdomains from one API.
More examples
Origins per environment
The allowed origins are data, not code, so they live in configuration and bind to the <code>CorsProperties</code> record. The <code>dev</code> profile replaces the whole list with the Vite development server (lists are replaced, not merged), so that origin never reaches production. A list in YAML binds directly to <code>List<String></code>; from environment variables use <code>APP_CORS_ALLOWEDORIGINS_0</code>, <code>APP_CORS_ALLOWEDORIGINS_1</code> and so on, or one comma-separated <code>APP_CORS_ALLOWEDORIGINS</code>.
# application.yaml
app:
cors:
allowed-origins:
- https://app.example.com
- https://admin.example.com
---
spring:
config:
activate:
on-profile: dev
app:
cors:
allowed-origins:
- http://localhost:5173
What a preflight looks like
The browser asks before sending the real <code>POST</code>, and only proceeds if the answer allows its origin, method and headers. The preflight has no <code>Authorization</code> header, which is why it must be answered by the CORS filter rather than reaching the authorisation rules. The <code>Vary</code> header stops a shared cache serving one origin's answer to another.
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Cookies from a subdomain front end
When the browser sends cookies, the response must name the exact origin and include <code>Access-Control-Allow-Credentials: true</code>. Origin patterns let you allow every subdomain you control while Spring still echoes back one concrete origin. Because session cookies are involved, CSRF protection has to stay on, which is why the CSRF header is in the allowed list.
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.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class CredentialedCorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Patterns are allowed with credentials; a bare "*" origin is not.
config.setAllowedOriginPatterns(List.of("https://*.example.com"));
config.setAllowCredentials(true);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Discussion