화면은 https://app.example.com, API는 https://api.example.com이라고 하자. 만료 토큰 요청에 API가 401을 반환해도 필요한 CORS 헤더가 없으면 JavaScript는 그 오류 본문을 읽지 못할 수 있다. 로그인 만료 안내를 만들려던 화면은 응답 상태를 판단할 재료를 잃는다.
이 글의 질문은 서버의 인증 실패 응답이 브라우저에 공개되려면 어디서 CORS를 처리해야 하는가다. Kotlin/JVM의 Servlet 기반 Spring MVC와 Spring Security 체인을 대상으로 설명한다.
HTTP 401과 fetch 실패를 먼저 나눈다
읽을 수 있는 HTTP 오류는 Response로 돌아오므로 상태를 검사한다. CORS 검사를 통과하지 못한 응답은 같은 방식으로 공개되지 않는다. Fetch의 응답 처리는 404 같은 HTTP 오류와 promise 거절을 구분한다.
async function loadMe(accessToken) {
const response = await fetch("https://api.example.com/v1/me", {
credentials: "include",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.status === 401) {
return { kind: "login-required" };
}
if (!response.ok) {
return { kind: "http-error", status: response.status };
}
return { kind: "success", data: await response.json() };
}
이 코드가 401 분기로 들어가려면 브라우저가 응답 접근을 허용해야 한다. 유효 토큰과 만료 토큰을 비교할 때 출처, URL, 메서드, 자격 증명 모드를 유지한다. credentials: "include"도 쿠키 범위·브라우저 정책을 대신하지 않는다.
OPTIONS가 막힌 것인지 실제 401이 가려진 것인지 본다
Authorization 헤더를 넣은 교차 출처 요청은 사전 요청 대상이다. 브라우저가 OPTIONS로 실제 메서드·헤더의 허용 여부를 먼저 확인한다. 사전 요청에 실제 요청의 자격 증명이 그대로 담기는 것은 아니다.
네트워크 탭에서 OPTIONS 뒤에 GET이 없는 경우와, GET이 401까지 만들었지만 공개되지 않는 경우를 나눈다. 전자는 사전 요청 정책을, 후자는 실제 응답의 헤더를 확인해야 한다. OPTIONS 캐시가 있으므로 매번 보이지 않는 것도 고려한다. 사전 요청 성공만으로 실제 오류 응답의 공개를 보장하지는 못한다.
MVC보다 앞에서 끝나는 응답을 포함한다
인증 필터는 컨트롤러를 호출하지 않고 401을 작성할 수 있다. Spring Security Servlet 아키텍처는 필터가 뒤 필터·Servlet 호출을 막거나 응답을 바꿀 수 있다고 설명한다.
요청 → CORS 처리 → 인증·인가 필터 → DispatcherServlet → Controller
└─ 인증 실패 응답
컨트롤러 CORS나 MVC 예외 처리만으로 이 앞단 응답까지 처리한다고 가정하면 빈 경로가 생긴다. OPTIONS만 인증 없이 허용하는 변경도 실제 401 헤더 누락을 해결하지 못할 수 있다. 허용된 출처의 헤더를 인증 실패 전에 준비할 경로가 필요하다.
아래는 OPTIONS를 통과한 실제 요청이 인증 필터에서 401로 끝나는 경로다. MVC까지 도달하지 않아도 응답 공개에 필요한 헤더가 남아 있어야 한다.
그림 1 필터의 401과 응답 공개 경로 — 인증 필터에서 끝난 응답도 허용된 출처의 CORS 헤더를 유지해야 화면이 401을 판단한다.
그림은 허용된 출처와 필요한 응답 헤더가 유지된 경우를 설명한다. 후속 처리의 response.reset() 등이 헤더를 지우는지도 확인한다. 미허용 출처에 응답 접근을 허용하는 것이 목표는 아니다.
공유 정책을 실제 Security 체인에 연결한다
다음 코드는 CORS 통합 부분을 보여준다. 인증 설정은 생략한 configureAuthentication에서 별도로 적용하는 예제다.
@Configuration(proxyBeanMethods = false)
class ApiSecurityConfiguration {
@Bean
fun corsSource(): UrlBasedCorsConfigurationSource {
val cors = CorsConfiguration().apply {
allowedOrigins = listOf("https://app.example.com")
allowedMethods = listOf("GET", "POST", "OPTIONS")
allowedHeaders = listOf("Authorization", "Content-Type", "X-Request-Id")
exposedHeaders = listOf("X-Request-Id")
allowCredentials = true
maxAge = 300L
}
return UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/**", cors)
}
}
@Bean
fun apiSecurity(
http: HttpSecurity,
corsSource: UrlBasedCorsConfigurationSource,
): SecurityFilterChain {
http.cors { cors -> cors.configurationSource(corsSource) }
configureAuthentication(http)
return http.build()
}
}
CorsConfiguration의 Java setter를 Kotlin 프로퍼티로 호출한다. corsSource는 메서드 매개변수로 주입하고 설정 클래스 안에서 @Bean 메서드를 직접 호출하지 않는다. 이 구조의 proxyBeanMethods = false는 Kotlin의 final 설정 클래스에서도 CGLIB 설정 프록시 없이 사용할 수 있다. configureAuthentication의 구현과 import는 생략했다.
Spring Security CORS 문서는 CORS 우선 처리와 CorsConfigurationSource 통합을 설명한다. MVC 정책을 Security가 사용하는 방식도 가능하다. 여러 정책·체인이 있으면 어떤 요청이 어느 체인과 정책을 쓰는지 명시한다.
MVC와 Security에 목록을 복사해 따로 고치면 응답 경로마다 달라질 수 있다. 사용자 정의 인증 필터가 Servlet 필터로도 등록되는지, 진입점·거절 처리기·예외 필터가 response.reset()으로 헤더를 지우는지도 확인한다. 이미 커밋된 응답에 뒤늦게 헤더를 붙이는 방식으로 보완하지 않는다.
자격 증명 요청에는 허용 목록의 구체적인 출처를 사용한다. Access-Control-Allow-Origin: * 조합은 자격 증명 요청의 와일드카드 제약에 걸린다. 출처는 스킴·호스트·포트이며 요청 Origin을 검증 없이 반사하지 않는다. CORS가 인증이나 쿠키 인증의 CSRF 정책을 대신하지도 않는다.
헤더 관찰과 브라우저 공개를 따로 검증한다
다음 호출은 서버 응답 헤더를 관찰하기 위한 예제다. curl은 브라우저의 CORS 차단을 수행하지 않는다.
curl -i -X OPTIONS http://localhost:8080/v1/me \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: authorization'
curl -i http://localhost:8080/v1/me \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer expired-token'
OPTIONS는 인증 오류로 종료되지 않고 요청한 메서드·헤더를 허용해야 한다. 실제 요청에는 401 상태와 함께 올바른 출처 및 필요한 자격 증명 헤더가 남아야 한다.
| 조건 | 브라우저에서 확인할 동작 |
|---|---|
| 허용 출처·정상 인증 | 성공 상태·본문을 읽는다 |
| 허용 출처·만료 인증 | 401로 로그인 안내를 선택한다 |
| 허용 출처·권한 부족 | 403을 읽는다 |
| 허용 출처·접근 가능한 미등록 경로 | MVC 404를 읽는다 |
| 미허용 출처 | 응답 접근이 허용되지 않는다 |
404 검증에는 인증된 요청이나 공개 경로를 사용한다. Security가 먼저 401로 종료하면 MVC 404를 본 것이 아니다. 프록시가 작성한 오류도 애플리케이션 필터를 통과하지 않으므로 별도 범위다.
필터 체인을 포함한 확인과 실제 브라우저 검사를 연결해야 한다. 목표는 모든 출처에 헤더를 붙이는 것이 아니라, 인증 상태를 유지한 채 허용된 화면이 필요한 오류 응답까지 읽게 하는 것이다.
