빌드노트
document.cookie에 없는데 서버에는 쿠키가 전달된다
HttpOnly 쿠키는 document.cookie에 없어도 HTTP 요청에 실릴 수 있다. 같은 경로의 일반 쿠키와 HttpOnly 쿠키를 실제 브라우저에서 비교하고, 만료 전후와 fetch 옵션에 따른 저장·전송·서버 수신 차이로 확인할 위치를 좁힌다.
로그인 응답을 받은 뒤 document.cookie에서 세션 쿠키가 보이지 않아도 저장 실패라고 결론 내릴 수 없다. HttpOnly 쿠키는 페이지의 JavaScript에서 읽을 수 없어도, 조건이 맞는 HTTP 요청에는 실린다. 로그인 문제를 확인하려면 JS에서 보이는 값과 브라우저의 저장 상태, 실제 요청을 나눠 봐야 한다.
MDN의 HttpOnly 설명 (새 탭에서 열림)은 fetch처럼 JavaScript가 시작한 요청에도 HttpOnly 쿠키가 전송될 수 있다고 설명한다. 값을 읽는 권한과 브라우저가 요청에 쿠키를 붙이는 동작은 다르다. 이 차이를 가짜 쿠키 두 개로 확인했다.
같은 경로인데 JS에는 하나만 보였다
Windows 11, Python 3.12.10, Playwright 1.59.0, Chromium 147.0.7727.15에서 별도 실험을 실행했다. 기존 로그인 상태를 가져오지 않은 새 브라우저 컨텍스트와 127.0.0.1의 임시 HTTP 서버를 사용했다. 이름과 값은 모두 실험용이다.
서버의 /lab/set 응답에서 다음 두 헤더를 각각 보냈다. 같은 호스트와 Path=/lab, 같은 수명과 SameSite 조건을 사용하고 HttpOnly 유무를 달리했다. 쿠키를 구별할 이름과 값도 다르게 정했다.
Set-Cookie: lab_visible=visible_demo; Path=/lab; Max-Age=5; SameSite=Lax
Set-Cookie: lab_hidden=hidden_demo; Path=/lab; Max-Age=5; SameSite=Lax; HttpOnly
설정 직후 브라우저 저장소를 자동화 API로 읽으니 두 쿠키가 모두 있었다. lab_visible의 HttpOnly는 false, lab_hidden은 true였다. 이 저장소 조회는 페이지의 JavaScript가 쿠키 값을 읽는 것과 별개의 관찰이다.
같은 /lab/set 페이지에서 읽은 결과는 하나뿐이었다.
document.cookie
// "lab_visible=visible_demo"
이어서 그 페이지의 JavaScript가 같은 origin의 /lab/probe?phase=before-expiry에 fetch 요청을 보냈다. origin은 프로토콜·호스트·포트의 조합이며, 이번에는 셋 모두 페이지와 같았다. 요청의 credentials는 same-origin으로 지정했다. 브라우저가 보낸 요청 헤더와 서버가 받은 헤더는 다음처럼 일치했다.
Cookie: lab_visible=visible_demo; lab_hidden=hidden_demo
lab_hidden은 JS에서 보이지 않았지만 저장돼 있었고, 요청에도 포함됐다. JS 코드가 숨겨진 값을 읽어서 헤더에 복사한 것이 아니다. 브라우저가 저장된 쿠키를 요청에 붙였다. RFC 6265의 HttpOnly 규칙 (새 탭에서 열림)은 스크립트에 쿠키를 노출하는 API에서 해당 쿠키를 제외하도록 설명한다.
만료된 쿠키는 저장소와 요청에서도 사라졌다
두 쿠키의 Max-Age=5는 5초의 수명을 지정한다. 설정 직후의 비교를 마치고 실제로 6초를 기다렸다. 쿠키 삭제 명령이나 브라우저 시계 변경은 사용하지 않았다. 그 뒤 같은 페이지에서 저장소를 다시 읽고 같은 경로에 fetch 요청을 보냈다.
설정 직후
저장소: lab_visible, lab_hidden
document.cookie: lab_visible=visible_demo
요청과 서버 수신: 두 쿠키 모두 있음
6초 대기 뒤
저장소: 두 쿠키 모두 없음
document.cookie: 빈 문자열
요청과 서버 수신: Cookie 헤더 없음
처음에 lab_hidden이 안 보인 상태와 만료 뒤 안 보인 상태는 원인이 달랐다. 처음에는 JS의 읽기만 제한됐고, 나중에는 저장소에도 쿠키가 없었다. document.cookie에서 이름을 찾지 못했다는 사실 하나로는 이 둘을 구별할 수 없다. MDN의 Max-Age 설명 (새 탭에서 열림)은 쿠키가 만료되기까지의 시간을 초 단위로 지정한다고 설명한다.
만료 전에는 요청 조건도 한 번 바꿨다. credentials: "omit"으로 보낸 fetch에는 두 쿠키가 모두 빠졌지만 저장소에는 두 행이 그대로 남았다. 이어 credentials 옵션을 생략한 동일 origin 요청에는 두 쿠키가 다시 실렸다. fetch의 credentials 기본값 (새 탭에서 열림)은 same-origin이다. 저장된 쿠키가 요청에 없을 때는 HttpOnly를 해제하기 전에 실제 요청 옵션을 확인해야 한다.
내 브라우저에서는 저장·전송·수신을 대조한다
Chrome에서는 개발자 도구의 Network 기록을 켜고 문제 동작을 다시 수행한다. 로그인 중 페이지가 이동해 기록이 지워지면 Preserve log를 켠다. 같은 쿠키 이름을 기준으로 다음 세 곳을 확인한다.
- 브라우저 저장소: Application → Storage → Cookies에서 해당 사이트를 선택한다. 쿠키 이름, Domain, Path, Expires / Max-Age, HttpOnly를 읽는다. HttpOnly가 켜진 행은 페이지 JS에서 안 보여도 개발자 도구에서는 확인할 수 있다. 행 자체가 없다면 로그인 응답의
Set-Cookie와 브라우저의 차단 사유, 만료 여부부터 본다. 응답에 헤더가 있다는 사실만으로 저장 성공을 판단하지 않는다. Chrome의 쿠키 확인 안내 (새 탭에서 열림) - 실패한 요청: Network에서 실제로 비로그인 응답을 받은 API 요청을 선택한다. Request URL과 Request Headers의
Cookie에 해당 이름이 있는지 본다. 저장소에 있는데 요청에 없다면 만료 시점, URL에 맞는 Domain·Path·Secure·SameSite 조건과 credentials 옵션을 살핀다. 페이지 주소와 API 주소를 혼동하지 않는다. - 서버 수신: 같은 요청을 처리한 서버에서 쿠키 이름의 존재 여부를 대조한다. 브라우저 요청에는 있는데 서버에 없다면 중간 프록시와 서버의 헤더 처리 경로를 확인한다. 서버가 받았는데도 비로그인이라면 세션 조회·서버 측 만료·인증 처리로 조사 대상을 옮긴다. 실제 세션 값 전체를 진단 로그에 남길 필요는 없다.
이번 실험에서 /lab/probe는 받은 가짜 헤더를 응답으로 돌려주는 관찰용 경로였고, 쿠키가 있을 때와 없을 때 모두 200을 반환했다. 이 200은 로그인 성공을 뜻하지 않는다. 자기 서비스에서는 쿠키 전달 여부와 인증이 필요한 API의 처리 결과를 각각 확인해야 한다.
HttpOnly 쿠키를 JS에서 못 읽는다는 이유로 로그아웃 처리하는 코드가 있다면, 그 조건으로 로그인 상태를 판정하지 않도록 바꾼다. HttpOnly를 끄거나 세션 값을 JS가 읽는 곳에 복사할 이유는 없다. 서버가 세션을 확인한 결과를 사용하면 된다. HttpOnly 자체가 JavaScript의 인증된 요청을 막아 주는 것도 아니다. 이번 비교에서도 JavaScript가 시작한 요청에 두 쿠키가 함께 실렸다.
직접 확인한 범위는 같은 origin의 로컬 HTTP와 위 Chromium 버전에서 합성 쿠키를 저장하고, 읽고, 전송하고, 만료시킨 과정이다. 실제 계정의 로그인, 서버 세션의 유효성, HTTPS의 Secure 조건, 교차 origin·교차 사이트 요청과 다른 브라우저는 검증하지 않았다. 이 결과로 다른 환경의 쿠키 전송까지 보장할 수는 없다.