빌드노트
포트가 다른데 SameSite=Strict 쿠키가 전송되는 이유
포트가 달라도 같은 site일 수 있다. 새 브라우저에서 같은 호스트의 다른 포트와 localhost 출발 요청을 비교하고, Strict·Lax 쿠키의 실제 수신 결과로 SameSite, fetch credentials, CORS를 나눠 진단한다.
프런트와 API의 포트가 다른데 SameSite=Strict 쿠키가 API로 전송돼도 이상한 동작은 아니다. SameSite가 비교하는 site에는 포트가 들어가지 않는다. 같은 스킴·같은 호스트에서 포트만 달라지면 교차 origin이면서 같은 site다. 이때 fetch에 credentials: "include"를 지정하면 다른 전송 조건을 만족하는 Strict 쿠키도 실릴 수 있다.
반대로 같은 주소 조합에서 쿠키가 빠져도 바로 SameSite 때문이라고 판단할 수 없다. fetch의 credentials 기본값은 same-origin이므로 포트가 다른 요청에는 기본적으로 쿠키를 보내지 않는다. SameSite의 site와 credentials의 origin을 구분해야 같은 요청을 설명할 수 있다. MDN의 site 정의 (새 탭에서 열림), fetch의 credentials 설명 (새 탭에서 열림)
포트와 호스트를 따로 바꿨다
이를 확인하려고 새 브라우저 프로필에서 가짜 쿠키만 쓰는 loopback 실험을 실행했다. 실제 로그인이나 운영 장애의 기록은 아니다. Windows 11, Python 3.12.10, agent-browser 0.27.0, Headless Chromium 153을 사용했다. 프런트는 http://127.0.0.1:24634, API는 http://127.0.0.1:24635였다. 같은 프런트를 http://localhost:24634로도 열었다.
일반적인 HTTP·HTTPS 주소의 origin은 스킴·호스트·포트로 구분한다. SameSite에서 쓰는 schemeful site는 스킴과 등록 가능 도메인을 기준으로 한다. 예를 들어 app.example.com과 api.example.com은 스킴이 같으면 같은 site다. IP 주소처럼 등록 가능 도메인이 없는 경우에는 호스트 자체를 사용한다. HTML 표준의 origin 및 site 계산 (새 탭에서 열림)
이번 주소에 적용하면 다음과 같다.
127.0.0.1:24634→127.0.0.1:24635: 둘 다 HTTP이므로 같은 site, 포트가 다르므로 다른 origin이다.localhost:24634→127.0.0.1:24635: 둘 다 HTTP이지만 호스트가 달라 다른 site이며 다른 origin이다. 같은 PC로 연결된다는 사실이 두 호스트를 같게 만들지는 않는다.
스킴도 빠뜨리면 안 된다. 같은 도메인이라도 HTTP와 HTTPS는 schemeful site가 다르다. 이 스킴 차이는 문서로 확인한 규칙이며, 이번 HTTP 실험에서는 직접 바꾸지 않았다. MDN의 schemeful same-site 설명 (새 탭에서 열림)
먼저 API 주소를 최상위 페이지로 열어 다음 두 Set-Cookie 헤더를 받았다. 쿠키 이름 외에는 SameSite 값만 다르게 했다.
Set-Cookie: lab_lax=synthetic; Path=/; SameSite=Lax
Set-Cookie: lab_strict=synthetic; Path=/; SameSite=Strict
브라우저 저장소에서 두 행의 Domain은 127.0.0.1, Path는 /였다. Domain 속성을 생략했으므로 이 호스트에만 해당하는 쿠키다. 쿠키는 포트별로 격리되지 않지만, 127.0.0.1의 쿠키가 localhost의 쿠키가 되는 것은 아니다. MDN의 Domain 설명 (새 탭에서 열림), RFC 6265의 포트 경계 (새 탭에서 열림)
따라서 아래 비교에서는 요청 목적지를 계속 127.0.0.1:24635로 유지했다. 쿠키가 없는 다른 호스트로 목적지를 바꾼 결과를 SameSite 차단으로 오해하지 않기 위해서다.
같은 site여도 fetch 기본값에서는 빠졌다
API와 같은 origin의 페이지에서 credentials 옵션을 생략한 GET에는 두 쿠키가 모두 도착했다. 이어 프런트 페이지에서 같은 API의 관찰용 GET을 호출했다. 별도 요청 헤더나 본문은 붙이지 않았다. 서버가 받은 Cookie와 JavaScript의 본문 읽기를 비교한 결과다.
출발 페이지 fetch credentials 서버가 받은 쿠키 JS 본문 읽기
127.0.0.1:24634 생략 없음 성공
127.0.0.1:24634 include lab_lax, lab_strict 성공
localhost:24634 include 없음 성공
쿠키가 도착한 두 번째 요청의 실제 수신 헤더는 Cookie: lab_lax=synthetic; lab_strict=synthetic였다. 브라우저가 붙인 Sec-Fetch-Site도 앞의 두 행에서는 same-site, 마지막 행에서는 cross-site였다. 모든 요청은 같은 API에 도착했고, 관찰용 API는 쿠키 유무와 관계없이 200을 보냈다. 본문 읽기 성공을 인증 성공으로 해석한 실험은 아니다.
첫 번째와 두 번째 행의 차이는 credentials다. 쿠키의 SameSite 값을 바꾸지 않고 include로 바꾸자 Strict까지 전송됐다. 다른 포트에 쿠키를 보내려는 상황이라면 SameSite를 None으로 낮추기 전에 실제 fetch 옵션부터 확인한다. fetch(apiUrl, { credentials: "include" })의 apiUrl은 기존 호출의 API 주소다. 교차 origin에 쿠키를 전달해야 하는 호출에 이 옵션이 있는지 확인하면 된다.
세 번째 행은 include가 SameSite 조건을 무시하지 못한다는 문서 규칙과 일치한다. Lax와 Strict는 교차 site의 fetch에 허용되지 않는다. 다만 이번에는 브라우저의 제3자 쿠키 차단이 추가로 작용했는지까지 분리하지 않았다. 쿠키는 비교를 마친 뒤 API 주소의 저장소에서도 두 행 모두 확인됐다. 요청에 없다는 이유만으로 삭제됐다고 볼 수 없다. MDN의 SameSite 값 설명 (새 탭에서 열림)
같은 목적지를 링크로 열자 Lax만 도착했다
localhost:24634의 합성 페이지에서 API로 가는 링크를 눌렀다. 주소 표시줄이 API 주소로 바뀌는 최상위 GET 탐색이다. 첫 탐색 요청에서 서버가 받은 헤더는 다음과 같았다.
Cookie: lab_lax=synthetic
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Strict는 없었고 Lax는 있었다. 같은 목적지를 향한 GET이라도 fetch와 최상위 탐색의 조건이 다르다. 명시적인 SameSite=Lax는 교차 site 요청 중 최상위 탐색이면서 안전한 메서드인 경우를 허용한다. 링크를 통한 GET이 여기에 해당한다. fetch는 주소 표시줄을 바꾸지 않으므로 이 예외에 해당하지 않는다. MDN의 Lax 탐색 조건 (새 탭에서 열림)
같은 site인 127.0.0.1:24634에서 링크를 눌렀을 때는 두 쿠키 모두 도착했다. 따라서 “링크로 들어가면 쿠키가 오는데 fetch에서는 빠진다”는 관찰만으로 저장 실패나 서버의 세션 삭제를 결론 내리면 안 된다. 출발 페이지의 site, 실제 요청 방식, credentials를 함께 봐야 한다.
CORS 오류가 쿠키 미전송을 뜻하지는 않는다
같은 site의 include 요청에서 CORS 응답 헤더만 생략한 조건도 실행했다. 서버에는 두 쿠키가 그대로 도착했지만 fetch는 TypeError: Failed to fetch로 거절됐다. 앞의 읽기 성공 조건에서는 서버가 허용한 프런트의 정확한 origin을 Access-Control-Allow-Origin으로 보내고 Access-Control-Allow-Credentials: true를 함께 보냈다.
이 단순 GET 비교에서 CORS는 이미 도착한 쿠키를 회수하는 장치가 아니었다. 다른 origin의 JavaScript가 응답을 읽을 수 있는지가 달라졌다. 별도 헤더나 메서드 때문에 preflight가 필요한 요청이라면 실제 요청 전송부터 달라질 수 있으므로 이 결과를 모든 CORS 요청에 확대하지 않는다. MDN의 credentials 요청 처리 (새 탭에서 열림)
자기 요청을 진단할 때는 개발자 도구에서 다음 순서로 확인한다.
- 페이지 주소와 실제 Request URL을 비교한다. 포트만 다른지, 스킴이나 호스트도 다른지 적는다.
localhost와127.0.0.1을 섞어 썼는지 확인한다. - 저장된 쿠키와 대상 호스트를 맞춘다. Chrome의 Application → Storage → Cookies에서 Domain·Path·Secure·SameSite를 읽는다. 행이 없으면 설정 응답과 차단·만료 사유부터 확인한다. Chrome의 쿠키 확인 안내 (새 탭에서 열림)
- 쿠키 전송과 응답 읽기를 각각 확인한다. fetch를 만드는 코드의 credentials, Network의 해당 요청 Cookie와 제외 사유를 확인한다. 서버에서는 같은 요청의 쿠키 이름 존재 여부만 대조해도 된다. 쿠키는 도착했는데 JS만 실패한다면 실제 응답의 CORS 헤더로 조사 대상을 옮긴다.
이번에 직접 확인한 범위는 새 프로필의 두 loopback HTTP 서버, 합성 Lax·Strict 쿠키, GET fetch와 링크 탐색이다. 쿠키·CORS 정책을 해제하는 옵션이나 개인 쿠키는 사용하지 않았고, 실험 뒤 브라우저와 두 서버를 닫았다. SameSite=None; Secure를 쓰는 교차 site 대조군, HTTPS 전환, iframe·리다이렉트·POST, 다른 브라우저 엔진은 실행하지 않았다. 특히 제3자 쿠키 허용 여부는 별도로 판정하지 못했다. CORS를 허용하고 include를 지정해도 브라우저의 쿠키 차단 정책은 따로 적용될 수 있다. MDN의 제3자 쿠키 제한 (새 탭에서 열림)