- Published on
Spring Security 6 JWT 403·CORS 프리플라이트 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 JWT 인증을 붙였는데 갑자기 API가 403 Forbidden을 뱉거나, 브라우저에서만 CORS preflight가 실패하는 경우는 대부분 Spring Security 6 필터 체인과 CORS 처리 위치가 맞지 않아서 발생합니다. 특히 OPTIONS 요청(프리플라이트)은 인증 헤더가 없고, 컨트롤러까지 도달하기 전 시큐리티 레이어에서 먼저 차단되기 때문에 “서버는 정상인데 브라우저만 안 됨” 같은 증상이 자주 나옵니다.
이 글에서는 Spring Security 6 + JWT(Stateless) 구성에서 자주 만나는 403과 프리플라이트 문제를 재현 가능한 원인 단위로 분해하고, 실무에서 안전하게 적용할 수 있는 설정 패턴을 코드로 정리합니다.
운영 환경에서 이런 류의 장애는 원인 파악이 늦어지면 사용자 체감이 커집니다. 비슷하게 “설정은 맞는데 특정 조건에서만 500/403이 발생”하는 케이스를 다룬 글로 Next.js ISR 500 - revalidate·캐시 충돌 해결도 참고하면, 재현 조건을 좁히는 접근이 도움이 됩니다.
증상 패턴: 403과 프리플라이트 실패는 어떻게 다르게 보이나
1) JWT 인증 API가 전부 403
- Postman에서도 403
- 서버 로그에
AccessDeniedException또는AuthenticationCredentialsNotFoundException - 원인 후보: 필터 순서,
authorizeHttpRequests매칭,AnonymousAuthenticationFilter동작,AuthenticationEntryPoint미구성
2) 브라우저에서만 403 / CORS 에러
- 콘솔에
CORS policy관련 메시지 - 네트워크 탭에서
OPTIONS요청이 403 또는 401 - 원인 후보:
cors()미활성화,CorsConfigurationSource미등록,OPTIONS허용 규칙 부재, JWT 필터가OPTIONS까지 인증 강제
3) 200이지만 브라우저가 막음
- 서버는 응답했는데
Access-Control-Allow-Origin헤더가 없음 - 원인 후보: CORS가 Spring MVC 레벨에서만 설정되고, Security 레벨에서 누락
핵심 원리: CORS는 “컨트롤러 이전”에 끝나야 한다
브라우저의 프리플라이트는 대략 이런 형태입니다.
- 메서드:
OPTIONS - 헤더:
Origin,Access-Control-Request-Method,Access-Control-Request-Headers - 특징:
Authorization헤더가 없을 수 있음 (정확히는 프리플라이트 자체는 본 요청을 대신하는 탐색 요청)
따라서 아래 중 하나라도 해당하면 프리플라이트가 터집니다.
OPTIONS가 인증 필요 경로로 분류되어 JWT 필터가 401/403을 반환- Security가 CORS 헤더를 붙이기 전에 응답이 종료됨
allowCredentials(true)인데allowedOrigins("*")를 사용 (스펙 위반)
권장 구성: SecurityFilterChain + CorsConfigurationSource
아래 예시는 Spring Boot 3.x + Spring Security 6 기준의 “가장 흔한” JWT Stateless API 템플릿입니다.
1) CORS 설정을 Bean으로 올리고 Security에서 활성화
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 중요: Security 레벨에서 CORS 활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
// 프리플라이트는 무조건 통과시키는 것이 안전
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 인증 없이 열어둘 엔드포인트
.requestMatchers("/auth/login", "/auth/refresh", "/health").permitAll()
// 나머지는 인증 필요
.anyRequest().authenticated()
)
// JWT 필터는 UsernamePasswordAuthenticationFilter 앞에
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 401/403 응답을 명확하게 분리(디버깅/프론트 처리에 중요)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"message\":\"Unauthorized\"}");
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"message\":\"Forbidden\"}");
})
)
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 쿠키/인증정보를 보낼 거면 allowedOrigins("*") 불가
config.setAllowedOrigins(List.of(
"http://localhost:3000",
"https://app.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("Authorization"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
체크포인트
cors(...)는 반드시 SecurityFilterChain 안에서 활성화하세요.WebMvcConfigurer#addCorsMappings만으로는 Security에서 차단될 수 있습니다..requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()은 프리플라이트 이슈를 가장 빠르게 제거합니다.allowCredentials(true)를 쓰면allowedOrigins("*")대신 명시 도메인을 넣거나, 필요한 경우allowedOriginPatterns를 사용해야 합니다.
2) JWT 필터에서 OPTIONS는 건드리지 않기
JWT 필터가 프리플라이트까지 토큰 검증을 시도하면, 토큰이 없으니 401/403을 내고 CORS 헤더도 못 붙인 채 종료되는 경우가 많습니다. 필터에서 OPTIONS는 빠르게 통과시키는 방식을 권장합니다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
403이 계속 날 때: “인증 실패(401)”와 “인가 실패(403)”부터 분리
현장에서 403 문제를 오래 끄는 가장 흔한 이유는, 사실은 401인데 403처럼 보이거나(혹은 반대) 로그/응답이 뭉개져서입니다.
- 401: 인증 정보가 없음/유효하지 않음 (
AuthenticationEntryPoint) - 403: 인증은 됐지만 권한 부족 (
AccessDeniedHandler)
위에서 exceptionHandling을 명시해두면 프론트/QA가 재현 조건을 빠르게 좁힐 수 있습니다.
자주 틀리는 매칭 예시
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
위처럼 작성하면 /api/admin/**도 앞의 /api/**에 먼저 매칭되어 authenticated()만 요구될 수 있습니다. Spring Security 6의 매처는 선언 순서가 중요합니다. 더 구체적인 경로를 먼저 두세요.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
CORS 설정에서 특히 많이 터지는 4가지
1) allowCredentials(true) + 와일드카드 Origin
- 잘못된 예:
allowedOrigins("*") - 해결: 정확한 Origin을 나열하거나
allowedOriginPatterns사용
config.setAllowedOriginPatterns(List.of(
"https://*.example.com",
"http://localhost:3000"
));
config.setAllowCredentials(true);
2) Authorization 헤더를 허용하지 않음
브라우저는 Authorization을 “비단순 헤더”로 취급합니다. 프리플라이트의 Access-Control-Request-Headers에 authorization이 들어오는데 서버가 이를 허용하지 않으면 차단됩니다.
- 해결:
setAllowedHeaders에Authorization포함
3) 프록시/게이트웨이에서 OPTIONS를 막음
Nginx, ALB, API Gateway, 사내 WAF가 OPTIONS를 차단하면 애플리케이션 설정을 아무리 고쳐도 실패합니다.
- 확인: 브라우저 네트워크 탭에서
OPTIONS응답 헤더가 아예 없거나, 애플리케이션 로그에 요청이 찍히지 않음
EKS나 인그레스 계층에서 4xx/5xx가 섞이는 경우는 애플리케이션 밖에서 막힐 가능성도 큽니다. 인프라 레이어 점검 관점은 EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지처럼 “요청이 어디서 끊기는지”부터 추적하는 방식이 유효합니다.
4) Spring MVC CORS만 설정하고 Security CORS는 누락
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}
이 설정은 컨트롤러로 들어오는 요청에는 적용되지만, Security에서 먼저 거르면 CORS 헤더가 붙기 전에 끝날 수 있습니다. 결론은 간단합니다.
- SecurityFilterChain에서
cors()를 켜고 - CorsConfigurationSource를 등록하세요.
디버깅 루틴: 5분 안에 원인 좁히기
1) 프리플라이트를 curl로 재현
브라우저 없이도 OPTIONS를 재현하면 원인 분리가 빨라집니다.
curl -i -X OPTIONS "http://localhost:8080/api/me" \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: authorization,content-type"
정상이라면 응답에 최소한 아래 헤더가 보여야 합니다.
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers- (쿠키 사용 시)
Access-Control-Allow-Credentials: true
2) Security 디버그 로그 켜기
logging.level.org.springframework.security=DEBUG
로그에서 어떤 필터/매처에서 거부되는지 확인하세요. 특히 FilterSecurityInterceptor, ExceptionTranslationFilter 근처가 힌트를 줍니다.
3) 응답이 403인데 서버에 요청이 안 찍힌다
이 경우는 대개 애플리케이션 앞단(프록시/인그레스/WAF)입니다. 반대로 서버 로그에 OPTIONS가 찍히는데 403이면 애플리케이션(Security/JWT 필터) 문제일 확률이 큽니다.
운영에서 안전한 권장안 요약
- Stateless JWT API에서는
csrf.disable()+SessionCreationPolicy.STATELESS가 일반적 cors()는 Security에서 활성화하고CorsConfigurationSource로 관리OPTIONS /**는permitAll()로 열어두고, JWT 필터에서도OPTIONS는 스킵- 401/403 핸들러를 분리해 “인증 실패 vs 권한 부족”을 명확히
allowCredentials(true)를 쓰면 Origin 와일드카드를 피하고, 필요한 경우allowedOriginPatterns사용
이 조합으로 구성하면 “JWT 붙였더니 갑자기 403”과 “브라우저에서만 CORS 프리플라이트 실패”를 대부분 한 번에 정리할 수 있습니다.