- Published on
Spring Boot 3 CORS 프리플라이트 403 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 정상인데 브라우저에서만 API 호출이 실패하고, 개발자도구 네트워크 탭에는 OPTIONS 요청이 403 Forbidden으로 찍히는 상황은 Spring Boot 3에서 특히 자주 마주칩니다. 이유는 단순히 “CORS 설정을 안 했다”라기보다, CORS 처리보다 먼저 Spring Security 필터 체인이 OPTIONS 요청을 거부하거나, CORS 설정이 MVC에만 있고 Security에는 반영되지 않거나, 프록시/게이트웨이에서 OPTIONS가 차단되는 등 “경로”가 여러 갈래이기 때문입니다.
이 글은 Spring Boot 3 + Spring Security 6 기준으로, CORS 프리플라이트 403을 재현 가능한 관점에서 분해하고, 가장 안전한 해결 패턴을 코드로 제시합니다.
프리플라이트 OPTIONS가 왜 생기고, 왜 403이 되나
브라우저는 다음 조건 중 일부를 만족하면 실제 요청(GET, POST 등) 전에 **프리플라이트 OPTIONS**를 보냅니다.
Authorization같은 “비단순 헤더”를 보낼 때Content-Type: application/json처럼 단순 요청이 아닌 경우PUT,PATCH,DELETE등 단순 메서드가 아닐 때
프리플라이트 요청에는 보통 아래 헤더가 포함됩니다.
OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
서버는 OPTIONS에 대해 2xx로 응답하면서 다음과 같은 CORS 응답 헤더를 내려줘야 합니다.
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers- 필요 시
Access-Control-Allow-Credentials
문제는 Spring Boot 3에서 CORS 응답 헤더를 붙이기 전에 Security가 OPTIONS를 403으로 끊어버릴 수 있다는 점입니다. 즉, “CORS 설정이 맞는데도 403”이 가능합니다.
가장 흔한 원인 5가지
1) CorsConfigurationSource가 Security에 연결되지 않음
WebMvcConfigurer#addCorsMappings만 설정하면 MVC 레벨에서만 적용되고, Security 필터 체인에서 프리플라이트가 먼저 막히면 무용지물이 됩니다.
해결은 SecurityFilterChain에서 cors를 활성화하고, CorsConfigurationSource 빈을 제공하는 것입니다.
2) OPTIONS가 인증/인가 대상으로 걸려 403
anyRequest().authenticated() 같은 규칙이 있는데, OPTIONS를 별도로 허용하지 않으면 프리플라이트가 인증 없이 들어오면서 거부될 수 있습니다.
3) CSRF가 OPTIONS를 막는 구성
일반적으로 프리플라이트는 CSRF 토큰이 없고, 세션 기반 설정이 섞여 있으면 예상치 못한 거부가 발생합니다. API 서버라면 대개 CSRF를 끄거나, 특정 경로만 예외 처리합니다.
4) allowCredentials(true)와 와일드카드 오리진 조합
Access-Control-Allow-Credentials: true를 쓰면서 Access-Control-Allow-Origin: * 를 쓰면 브라우저가 정책상 거부합니다. 이 경우 서버 로그는 200인데도 브라우저에서는 실패로 보일 수 있어 더 헷갈립니다.
Spring에서는 allowedOrigins("*") 대신 allowedOriginPatterns("https://*.example.com") 같은 패턴 기반 또는 명시적 오리진 목록을 사용해야 합니다.
5) 프록시/게이트웨이에서 OPTIONS 차단
Nginx, ALB, API Gateway, Cloudflare 등 앞단에서 OPTIONS를 허용하지 않으면 애플리케이션까지 도달하지 않습니다. 네트워크 탭에서 응답 헤더가 거의 없거나, 서버 로그에 OPTIONS가 아예 안 찍히면 이 케이스를 의심하세요.
진단 체크리스트 (재현 → 확인 → 결론)
1) 브라우저 네트워크 탭에서 프리플라이트 확인
- 요청 메서드가
OPTIONS인지 - Request Headers에
Origin과Access-Control-Request-*가 있는지 - Response Headers에
Access-Control-Allow-*가 있는지
403인데 CORS 헤더가 없다면, 대개 Security 또는 프록시에서 먼저 막혔습니다.
2) curl로 프리플라이트를 그대로 재현
아래처럼 프리플라이트를 흉내 내면 서버가 어떤 헤더를 주는지 빠르게 확인할 수 있습니다.
curl -i -X OPTIONS "http://localhost:8080/api/v1/orders" \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
기대 결과는 HTTP/1.1 200 또는 204이며, 최소한 Access-Control-Allow-Origin이 포함되어야 합니다.
3) Spring Security 디버그 로그로 필터 체인 확인
logging.level.org.springframework.security=DEBUG
로그에서 OPTIONS 요청이 어떤 필터에서 거부되는지 확인합니다.
정석 해결: SecurityFilterChain + CorsConfigurationSource
Spring Boot 3에서 가장 안정적인 패턴은 Security에서 CORS를 처리하게 만드는 것입니다.
아래 예시는 JWT 기반 API 서버를 가정합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of(
"http://localhost:3000",
"https://*.example.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
config.setExposedHeaders(List.of("Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
핵심 포인트는 다음입니다.
http.cors(cors -> {})로 Security에 CORS를 켭니다.CorsConfigurationSource빈을 제공해 Security가 참조하도록 합니다.- 프리플라이트는 인증이 목적이 아니므로
requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()로 열어둡니다. allowCredentials(true)를 쓰면 오리진을*로 두면 안 됩니다. 반드시 명시 또는 패턴을 사용합니다.
MVC 레벨 CORS 설정을 쓰고 싶다면 (주의점 포함)
MVC의 addCorsMappings는 컨트롤러 레벨에서 동작합니다. 하지만 Security가 앞에서 막으면 적용되지 않습니다. 그래도 MVC 설정을 유지하고 싶다면 Security에도 cors를 켜야 합니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("http://localhost:3000", "https://*.example.com")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
이 방식의 함정은 “MVC에는 있는데 Security에는 없어서 403”이 다시 발생하기 쉽다는 점입니다. 팀 단위로 운영한다면 Security 쪽에 단일 진실 공급원을 두는 편이 사고가 적습니다.
403인데도 CORS처럼 보이는 케이스들
케이스 A: 인증 서버/JWT 문제로 실제 요청이 401 또는 403
프리플라이트는 통과했는데 실제 GET 또는 POST가 403인 경우, CORS가 아니라 인증/인가 문제일 수 있습니다. 특히 JWKS 키 회전이나 캐시 문제로 JWT 검증이 실패하면 프론트에서는 “CORS 오류”처럼 보이기도 합니다.
- 관련해서 JWT 검증 실패의 전형적인 함정은 Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응 글이 도움이 됩니다.
- 일반적인 서명 오류 점검은 JWT invalid signature - JWK 회전·캐시 점검법도 함께 참고하세요.
케이스 B: 프록시에서 OPTIONS만 403
애플리케이션 로그에 OPTIONS 요청이 전혀 없다면, 앞단에서 막힌 것입니다. 이때는 Spring 설정을 아무리 바꿔도 해결되지 않습니다.
예를 들어 Nginx라면 location 블록에서 OPTIONS를 허용하거나, 프록시 패스 설정에서 메서드 제한을 풀어야 합니다.
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Allow-Credentials "true";
return 204;
}
proxy_pass http://app:8080;
}
운영 환경에서 403이 간헐적으로만 발생한다면, 앞단 인증(예: WAF 규칙)이나 토큰 만료/권한 문제처럼 “403의 다른 얼굴”일 수도 있습니다. 403을 넓게 다루는 관점에서는 K8s ImagePullBackOff - ECR 403·토큰 만료 해결처럼 “403을 만드는 레이어”를 분리해 보는 접근이 유용합니다.
실무에서 안전한 권장 구성
- CORS는 SecurityFilterChain 기준으로 단일화합니다.
- 프리플라이트는
permitAll로 열어두고, 실제 요청에서 인증/인가를 강제합니다. allowCredentials(true)가 필요 없다면 끄고, 오리진을 단순화합니다.- 오리진은 운영 도메인만 허용하고, 로컬 개발 오리진만 별도로 추가합니다.
- 프록시가 있다면
OPTIONS가 애플리케이션까지 도달하는지부터 확인합니다.
마무리
Spring Boot 3에서 CORS 프리플라이트 403은 대개 “CORS 설정 누락”이 아니라 Security 필터 체인과 CORS 처리 순서의 문제입니다. CorsConfigurationSource를 제공하고 http.cors를 활성화한 뒤, OPTIONS를 명시적으로 허용하면 대부분의 케이스를 깔끔하게 정리할 수 있습니다.
문제가 계속된다면 curl로 프리플라이트를 재현해 “애플리케이션까지 도달하는지”, Security 디버그 로그로 “어떤 규칙이 거부하는지”를 먼저 확정한 다음, 프록시 레이어까지 포함해 원인을 분리해 보세요.