빌드노트
서버에는 200인데 쿠키를 넣은 fetch는 응답을 못 읽는다
서버의 GET 200과 브라우저의 응답 읽기 성공은 다르다. 두 loopback origin에서 credentials와 CORS 응답 헤더의 8개 조합, 쿠키 삭제 뒤 2개 조건을 실제 비교하고 정확한 프런트 origin과 Allow-Credentials를 확인할 위치를 설명한다.
API 서버에는 GET과 200이 남는데, credentials: "include"를 넣은 fetch는 TypeError: Failed to fetch로 끝날 수 있다. 응답의 Access-Control-Allow-Origin이 *라면 먼저 그 조합을 확인한다. 교차 origin의 include 요청에서 응답을 읽으려면, 서버가 호출한 프런트의 정확한 origin과 Access-Control-Allow-Credentials: true를 함께 보내야 한다. 별표에 Allow-Credentials만 추가해서는 해결되지 않는다.
이때 요청 전송, 쿠키 전송, 응답 읽기는 서로 다른 결과다. 서버의 200은 요청을 처리했다는 기록이다. 그 응답을 다른 origin의 JavaScript에 공개할지는 브라우저가 CORS 응답 헤더로 판단한다. MDN의 credentials 요청 설명 (새 탭에서 열림)은 사전 요청이 없는 단순 GET에서도 이 읽기 제한이 적용된다고 설명한다.
GET과 쿠키는 이미 서버에 도착했다
이를 확인하려고 프런트 http://127.0.0.1:58284와 API http://127.0.0.1:58285를 따로 열었다. 실제 서비스 장애 기록이 아니라 격리된 브라우저에서 실행한 비교다. 서버는 모든 관찰용 GET에 200을 보내고, 브라우저는 응답 본문까지 읽었는지 기록했다.
새 프로필에 저장한 쿠키는 가짜 값 lab_cors=synthetic_only 하나다. 설정 응답에는 Path=/; SameSite=Lax를 붙였다. 두 주소는 포트가 달라 origin은 다르지만 같은 site다. site를 판단할 때는 포트를 구분하지 않는다. MDN의 site 설명 (새 탭에서 열림) 쿠키도 포트별로 격리되지 않으므로 한쪽에서 설정한 이 쿠키를 다른 포트의 API에 보낼 수 있었다. RFC 6265의 포트 경계 (새 탭에서 열림)
fetch는 GET으로 호출했고 Authorization이나 별도 요청 헤더를 붙이지 않았다. credentials를 omit/include로 바꾸면서 응답의 Allow-Origin과 Allow-Credentials를 비교했다. 아래에서 정확은 프런트 origin인 http://127.0.0.1:58284, 없음은 헤더 생략이다. 쿠키 수신은 API 서버가 받은 Cookie 헤더로 확인했다.
credentials Allow-Origin Allow-Credentials 쿠키 수신 JS 본문 읽기
omit * 없음 없음 성공
omit * true 없음 성공
omit 정확 없음 없음 성공
omit 정확 true 없음 성공
include * 없음 있음 실패
include * true 있음 실패
include 정확 없음 있음 실패
include 정확 true 있음 성공
8건 모두 API에 GET이 한 번씩 도착했고 서버는 200 응답을 썼다. OPTIONS는 한 건도 없었다. include의 네 조건에서는 모두 Cookie: lab_cors=synthetic_only가 도착했다. 그런데 본문을 읽은 것은 마지막 조건뿐이었다. 실패한 세 요청은 fetch가 TypeError로 거절되어 JavaScript에서 Response를 받지 못했다. 서버의 200을 JS의 response.status로 읽었다는 뜻이 아니다.
omit의 성공도 로그인 성공을 뜻하지 않는다. 이 관찰용 API는 쿠키가 없어도 200을 보냈다. omit 요청의 본문에는 cookieReceived: false, 마지막 include 요청에는 cookieReceived: true가 있었다. 쿠키가 필요한 API에서 오류를 없애려고 omit으로 바꾸면, 읽기는 가능해져도 사용자 세션은 전달되지 않을 수 있다.
쿠키를 지워도 include와 별표는 실패했다
쿠키를 삭제한 뒤 두 조건을 더 실행했다. include와 Allow-Origin: *, Allow-Credentials: true 조합은 여전히 TypeError였다. 정확한 프런트 origin과 Allow-Credentials를 보낸 조건에서는 200과 본문을 읽었다. 두 요청 모두 서버에 GET이 도착했으며 Cookie 헤더는 없었다.
따라서 “실제로 Cookie 헤더가 있을 때만 별표가 안 된다”라고 판단하면 놓치는 경우가 생긴다. CORS 검사는 요청의 credentials 모드가 include인지로 갈린다. Fetch 표준의 CORS 검사 (새 탭에서 열림)는 include가 아닐 때 별표를 허용하고, include라면 origin 일치와 Allow-Credentials 값을 확인한다. 쿠키가 현재 비어 있다는 사실로 include 응답의 조건이 느슨해지지는 않는다.
고칠 값은 API 주소가 아니라 호출한 페이지의 origin이다
위 실험에서 include 요청의 본문을 읽게 만든 응답 헤더는 다음과 같다. 이 값은 실험 주소이므로 자신의 서버에는 승인한 프런트의 origin을 넣는다.
Access-Control-Allow-Origin: http://127.0.0.1:58284
Access-Control-Allow-Credentials: true
Vary: Origin
Allow-Origin에 API 자신의 주소인 http://127.0.0.1:58285를 넣는 것이 아니다. 요청의 Origin과 비교할 값이다. 스킴·호스트·포트를 맞추며 경로나 끝의 /를 붙이지 않는다. Allow-Credentials 값은 소문자 true다. Fetch 표준의 credentials 조합 (새 탭에서 열림)
프런트가 여러 개라면 요청 Origin을 서버의 허용 목록과 대조하고, 허용된 경우 그 origin 하나를 응답한다. 아무 Origin이나 그대로 반사해 주는 설정으로 바꾸지 않는다. Origin에 따라 허용 헤더를 선택하는 응답에는 Vary: Origin을 넣어 캐시가 그 차이를 구분하게 한다. MDN의 Allow-Origin과 캐시 설명 (새 탭에서 열림) 이번 실험에는 Vary를 넣었지만 공유 캐시의 동작까지 측정한 것은 아니다.
이 헤더는 실제 GET 응답에 있어야 한다. OPTIONS 응답만 수정해도 이번처럼 사전 요청이 없는 GET의 읽기 조건은 바뀌지 않는다. 허용한 프런트가 인증 오류를 읽어야 한다면 401 같은 오류 응답에서도 같은 CORS 정책이 적용되는지 확인한다. 사용자 인증과 API 권한 검사는 그대로 필요하다. 이번 관찰처럼 CORS 읽기 실패 전에 요청과 쿠키가 이미 서버에 도착할 수 있기 때문이다.
자기 요청에서는 세 기록을 맞춰 본다
오류가 나는 프런트 페이지에서 개발자 도구의 Network 기록을 켜고 기존 호출을 한 번 실행한다. 비교할 대상은 페이지 주소와 실패한 API의 실제 Request URL이다.
- 요청이 도착했는가: 같은 URL·시각의 GET을 API 또는 게이트웨이 수신 기록에서 찾는다. OPTIONS만 있고 GET이 없다면 이 글의 응답 읽기 사례와 다르므로 preflight부터 조사한다.
- 쿠키가 실렸는가: fetch 또는 Request를 만드는 코드에서 실제 credentials 값을 확인하고, Network의 Request Headers와 서버에서 쿠키 이름의 존재 여부를 대조한다. 실제 세션 값 전체를 로그에 남길 필요는 없다.
- 응답을 공개했는가: 실제 GET 응답의 Allow-Origin이 요청 Origin과 같은지, Allow-Credentials가
true인지 확인한다. 서버 설정 파일만 보지 말고 프록시를 거쳐 최종 도착한 헤더를 읽는다. 수정 뒤 같은 호출에서 본문까지 읽히는지 다시 확인한다.
fetch의 credentials 기본값은 same-origin이다. 다른 origin의 쿠키를 쓰려는 호출이라면 include를 명시해야 하지만, include 자체가 쿠키의 모든 전송 조건을 충족시켜 주지는 않는다. MDN의 credentials 값 (새 탭에서 열림) 헤더를 고쳐 읽기가 성공했는데도 서버에 쿠키가 없다면 쿠키 저장 여부와 Domain·Path·Secure·SameSite, 브라우저의 제3자 쿠키 정책을 따로 살핀다. MDN의 제3자 쿠키 제한 (새 탭에서 열림)
mode: "no-cors"로 바꾸는 것도 JSON을 읽는 해결책이 되지 않는다. 문서상 이 응답은 opaque라서 JS에 본문과 헤더를 공개하지 않는다. MDN의 요청 모드 설명 (새 탭에서 열림)
직접 실행한 범위는 Windows 11, Python 3.12.10, agent-browser 0.27.0과 Headless Chromium 153의 두 loopback HTTP origin이다. 10개 비교에 새 전용 프로필과 합성 쿠키만 썼고, CORS 해제 옵션이나 요청 가로채기는 사용하지 않았다. 종료 후 브라우저와 서버를 닫았다. 서로 다른 사이트의 제3자 쿠키 차단, HTTPS의 Secure 쿠키, 실제 로그인·권한 검사, 운영 프록시, 다른 브라우저 엔진과 no-cors 전환은 별도로 실행하지 않았다.