빌드노트
Authorization을 넣었는데 GET 대신 OPTIONS가 401로 끝났다
Authorization을 붙인 교차 출처 GET이 실제 전송 전에 막혔는지 OPTIONS 응답과 서버 수신 기록으로 구별한다. 로컬 브라우저에서 OPTIONS 401, 허용 뒤 GET 200, 잘못된 토큰의 GET 401을 비교하고 실제 API 인증을 유지하는 명시적 CORS 정책을 설명한다.
다른 출처의 프런트에서 Authorization 헤더를 붙인 API 호출이 브라우저 오류로 끝났다면, Network에서 401을 받은 요청의 메서드부터 확인한다. OPTIONS가 401이고 실제 GET이 서버에 도착하지 않았다면, 먼저 고칠 곳은 사전 요청 처리다. GET의 토큰 검사를 없앨 이유는 없다.
브라우저는 이 교차 출처 GET을 보내기 전에 같은 주소에 OPTIONS 요청으로 허용 여부를 묻는다. 이 사전 요청을 preflight라고 한다. 서버가 OPTIONS에도 실제 API와 같은 Bearer 토큰을 요구하면, 프런트 코드에 토큰을 넣어 두었어도 여기서 막힌다. 아래는 실제 서비스 장애 기록이 아니라, 두 로컬 출처와 가짜 토큰으로 그 차이를 확인한 실험이다.
OPTIONS에는 토큰 값 대신 헤더 이름이 왔다
실험의 프런트 주소는 http://127.0.0.1:57194, API 주소는 http://127.0.0.1:57193이었다. 포트가 달라 출처가 다르다. 프런트는 GET에 Authorization: Bearer lab-good을 붙였다. credentials: 'omit'으로 실행했고 쿠키는 쓰지 않았다. 서버에서 받은 OPTIONS 헤더 중 이번 판단에 필요한 부분은 다음과 같다.
OPTIONS /auth-first?case=auth-first HTTP/1.1
Origin: http://127.0.0.1:57194
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization
이 OPTIONS에는 Authorization 헤더가 없었다. Access-Control-Request-Headers: authorization은 “실제 요청에 이 이름의 헤더를 쓰겠다”는 뜻이지 토큰을 전달한 것이 아니다. 서버의 인증 검사가 토큰 누락으로 401을 반환하자, 브라우저는 실제 GET을 보내지 않았다.
MDN의 preflight 설명 (새 탭에서 열림)도 요청할 메서드와 헤더 이름을 먼저 알리고 허용 응답을 확인하는 순서를 설명한다. 이 사례에서 OPTIONS에 토큰을 추가하도록 프런트 인터셉터를 고치는 접근은 맞지 않는다. 사전 요청은 브라우저가 별도로 구성한다.
사전 요청만 허용하자 실제 GET의 결과가 보였다
/auth-first는 토큰 검사를 OPTIONS 처리보다 먼저 실행했다. /preflight-ok는 허용한 Origin·메서드·헤더인지 먼저 검사하고 OPTIONS에 204를 반환했다. 두 경로 모두 실제 GET에서는 같은 가짜 토큰을 검사했다. 서버 수신 로그와 브라우저의 fetch 결과를 대조한 출력이다.
auth-first: OPTIONS=401 GET_count=0 fetch=rejected TypeError
preflight-ok: OPTIONS=204 GET_count=1 GET_status=200 fetch=resolved status=200
bad-token: OPTIONS=204 GET_count=1 GET_status=401 fetch=resolved status=401
첫 줄은 GET에 쓰려던 토큰이 lab-good인데도 GET 수신 횟수가 0이었다. 이 경우 토큰 값을 바꿔도 OPTIONS에는 그 값이 실리지 않는다. 두 번째 줄은 사전 요청을 허용하자 GET이 한 번 도착하고 200을 받은 결과다.
세 번째 줄은 허용 경로에 lab-wrong을 보낸 결과다. OPTIONS는 통과했지만 GET은 401이었다. 브라우저는 응답을 읽을 수 있었고, fetch는 status=401, ok=false인 응답으로 완료됐다. HTTP 오류 응답을 받는 것과 CORS 때문에 fetch 자체가 거절되는 것은 다르다. 이 실험에서는 GET의 401에도 허용한 프런트의 CORS 응답 헤더를 붙였다.
MDN의 fetch 문서 (새 탭에서 열림)는 HTTP 오류 상태만으로 Promise가 거절되지 않으므로 Response.status나 Response.ok를 확인하도록 설명한다. 실제 앱에서 401을 받았다고 catch로 넘어갔다면, 공통 응답 처리 코드가 !response.ok에서 직접 예외를 던지는지도 구별해야 한다.
자기 앱에서는 401의 메서드와 수신 로그를 함께 읽는다
오류가 나는 프런트 페이지에서 개발자 도구의 Network 기록을 연 뒤 기존 API 호출을 한 번 실행한다. API 주소로 요청을 찾고 메서드와 요청·응답 헤더를 읽는다. OPTIONS가 필터에 가려지지 않도록 전체 요청도 확인한다. 다음 결과에 따라 조사할 곳이 달라진다.
- OPTIONS 401:
Origin,Access-Control-Request-Method,Access-Control-Request-Headers가 있는지 확인한다. API 앞단의 인증 필터나 게이트웨이가 사전 요청을 먼저 거절하는지 조사한다. 서버 로그에서도 같은 URL·시각의 OPTIONS와 GET을 구분해 찾는다. - OPTIONS 성공 뒤 GET 401: 실제 요청이 도착했다. GET의 Authorization 전달 여부와 서버의 토큰 검증 결과를 확인한다.
- OPTIONS 204인데도 GET 미도착:
Access-Control-Allow-Origin이 프런트 출처와 맞는지, 요청한 메서드와 헤더를 정책에서 허용했는지 대조한다. 204라는 상태만으로 preflight가 성공하지는 않는다.
마지막 경우도 따로 실행했다. Access-Control-Allow-Headers를 Authorization 대신 X-Other로 응답하자, OPTIONS는 204였지만 GET은 0건이고 fetch는 TypeError로 끝났다. 콘솔에는 authorization 헤더가 허용되지 않았다는 메시지가 남았다. Fetch 표준의 preflight 절차 (새 탭에서 열림)는 성공 상태와 CORS 검사, 메서드·헤더 허용 조건을 함께 확인한다.
개발자 도구에는 보내지 못한 GET이 실패 행으로 표시될 수도 있다. 그 행이나 프런트의 TypeError만으로 전송 여부를 단정하지 말고, OPTIONS 응답과 API 또는 게이트웨이의 수신 로그를 같은 URL·시각으로 대조한다. 이 실험의 GET_count도 프런트 호출 횟수가 아니라 서버가 실제로 받은 횟수다.
OPTIONS만 먼저 처리하고 GET 인증은 유지한다
이 실험에서 선택한 정책은 허용한 프런트 출처 하나, 실제 메서드 GET, 요청 헤더 Authorization이다. 정상 경로의 OPTIONS 응답에서 필요한 부분을 추리면 다음과 같다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://127.0.0.1:57194
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: Authorization
Access-Control-Max-Age: 0
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
주소는 실험값이다. 자신의 서버에는 승인한 프런트의 스킴·호스트·포트를 정확히 넣는다. Origin을 검사 없이 그대로 되돌려 주거나 모든 Origin을 허용하는 방식으로 바꾸지 않는다. Authorization도 허용 헤더에 이름을 명시한다. Fetch 표준은 이를 와일드카드만으로 허용할 수 없는 헤더 이름 (새 탭에서 열림)으로 정의한다. 이 글의 측정은 Authorization을 명시한 응답과 X-Other만 허용한 응답을 비교한 것이다. 와일드카드 동작의 브라우저 호환성은 확인하지 못했다.
서버에서는 허용할 API 경로의 OPTIONS와 Origin·Access-Control-Request-Method가 있는 사전 요청을 식별하고, 출처·예정 메서드·요청 헤더를 허용 목록과 대조한다. 허용되면 토큰 인증 전에 빈 204로 끝낸다. 그다음 도착하는 실제 GET에는 기존 인증과 권한 검사를 적용한다. OPTIONS라는 이유로 모든 경로를 무조건 통과시키는 규칙으로 넓힐 필요는 없다.
허용 목록 밖의 값도 확인했다. 별도 HTTP 탐침으로 Origin, 예정 메서드, 요청 헤더를 하나씩 바꿨을 때 이 실험 서버는 각각 403을 반환했다. 이는 브라우저의 추가 출처를 띄운 실험이 아니라 서버 정책에 직접 보낸 세 요청의 결과다.
GET의 성공 응답뿐 아니라 인증 오류 응답에도, 허용한 Origin에 대한 CORS 헤더가 있어야 프런트가 401을 읽을 수 있다. 반대로 Origin 허용은 사용자 인증을 대신하지 않는다. 브라우저 밖의 클라이언트도 API를 호출할 수 있으므로 실제 API의 인증과 권한 검사는 계속 필요하다.
설정을 바꾼 뒤에는 허용한 프런트에서 정상 토큰과 잘못된 토큰으로 각각 호출해 본다. 이번 비교처럼 OPTIONS 허용 뒤 정상 토큰의 GET은 성공하고 잘못된 토큰의 GET은 401이어야 한다. OPTIONS만 직접 보내 204를 받는 것만으로는 브라우저가 실제 GET을 보냈는지까지 확인할 수 없다.
설정 변경 뒤 OPTIONS가 안 보이면 preflight 캐시의 영향도 확인한다. 이 실험은 새 브라우저 컨텍스트와 Access-Control-Max-Age: 0으로 이전 허용 결과가 비교를 가리지 않게 했다. MDN의 preflight 캐시 설명 (새 탭에서 열림)
재현 환경은 Windows 11, Node.js 22.23.2, Python 3.12.10, Playwright 1.59.0, Chromium 147.0.7727.15였다. 두 loopback HTTP 출처에서 합성 Bearer 문자열을 비교했으며, 요청 가로채기나 CORS 해제 옵션은 사용하지 않았다. 실제 JWT 검증, 쿠키 세션과 credentials: 'include', TLS 클라이언트 인증서, 운영 프록시, 다른 브라우저 엔진은 확인하지 않았다.