LabHub
시작하기
배우기 러닝패스 코스

세션과 토큰 — 브라우저에서 메시까지

브라우저는 규칙에 맞지 않는 쿠키를 조용히 버린다

LabHub 에서 이어서 보기

한 줄 요약

쿠키는 서버가 브라우저에 맡겨 두는 작은 값이고, 여섯 가지 속성이 어디로·언제까지·누구에게 보이게 를 정한다. 세션 쿠키는 그 값 하나가 곧 로그인이라 속성 하나만 빠져도 규칙이 무너지는데, 브라우저는 규칙에 맞지 않는 쿠키를 아무 오류 없이 버린다. 그래서 쿠키는 "보냈다" 가 아니라 "받아들여졌다" 까지 확인해야 한다.

왜 이게 필요했나

HTTP 는 요청마다 기억이 없다. 로그인한 사람을 다음 요청에서 알아보려면 무언가를 매번 다시 보내게 해야 하고, 그 무언가가 쿠키다. 편리한 이유는 브라우저가 쿠키를 자동으로 붙여 주기 때문인데, 위험한 이유도 똑같다. 어느 요청에 붙일지, 스크립트에 보여 줄지, 평문 HTTP 로도 보낼지를 서버가 분명히 말하지 않으면 브라우저는 가장 느슨한 쪽으로 동작한다.

원래 규격인 RFC 6265 는 이 느슨함을 숨기지 않는다. 쿠키는 포트로 격리되지 않아 같은 호스트의 다른 포트에서 도는 서비스도 같은 쿠키를 읽는다. foo.example.comDomain=example.com 으로 쿠키를 심으면 bar.example.com 의 쿠키를 덮어쓸 수 있고, 받는 서버는 그것이 누가 심은 것인지 구별하지 못한다. Path 로 경로를 나누는 것도 보안 경계로 믿을 수 없다고 적혀 있다. 속성과 이름 접두는 이 구멍을 하나씩 좁히려고 나중에 덧붙인 장치다.

어떻게 동작하나

속성은 Set-Cookie 한 줄 뒤에 세미콜론으로 붙는다. MDN Set-Cookie 기준으로 정리하면 이렇다.

Secure           HTTPS 요청에만 붙인다
HttpOnly         document.cookie 로 읽을 수 없다. 요청에는 그대로 붙는다
SameSite         다른 사이트에서 시작된 요청에 붙일지 (Strict / Lax / None)
Path             이 경로 아래 요청에만 붙인다. 보안 경계는 아니다
Domain           생략하면 보낸 호스트에만(호스트 전용), 적으면 그 도메인과 하위 전부
Max-Age/Expires  없으면 세션 쿠키, 있으면 영속 쿠키. 둘 다 있으면 Max-Age 가 이긴다

직관과 반대인 것이 Domain 이다. Domain=app.example.com 을 적으면 더 좁아질 것 같지만 실제로는 x.app.example.com 같은 하위 호스트에도 쿠키가 간다. 가장 좁은 범위는 생략이다. OWASP 세션 관리 치트시트 도 Domain 을 아예 빼라고 권한다. 같은 문서는 세션 쿠키를 영속으로 만들지 말라고 한다 — 브라우저를 닫으면 사라지게 두는 편이 낫다. 영속 쿠키에는 상한도 있다. RFC 6265bis 초안 은 만료를 400일보다 멀리 두지 말라고 적었다.

HttpOnly 가 막는 것은 딱 하나, 스크립트가 값을 읽는 것이다. MDN 은 HttpOnly 쿠키도 fetch() 요청에는 붙는다고 분명히 적는다. 페이지에 XSS 가 있으면 공격 스크립트는 쿠키를 훔치지 못할 뿐, 그 브라우저 안에서 사용자처럼 요청을 보내는 것은 그대로 할 수 있다. HttpOnly 는 XSS 방어가 아니라 피해를 줄이는 장치다.

이름 접두는 쿠키 저장소의 약점을 메운다. 저장소는 이름·도메인·경로로 쿠키를 구분할 뿐 누가 심었는지 는 기록하지 않는다. 접두는 조건을 이름 자체에 새겨서, 그 이름의 쿠키가 있다면 조건을 통과해 저장된 것이라고 서버가 믿을 수 있게 한다. RFC 6265bis 초안(22판)의 저장 모델 기준이다.

__Secure-   Secure 가 있어야 한다
__Host-     Secure 가 있고, Domain 이 없고(호스트 전용), Path 속성이 / 여야 한다
            브라우저는 접두를 대소문자 구분 없이 대조한다 (__host- 도 같은 규칙)

같은 초안은 보안 연결이 아닌 응답이 보낸 Secure 쿠키를 무시하라고 한다. MDN 은 localhost 를 예외로 적어 두었고, 이 실습 파드의 curl 8.5 는 http://127.0.0.1 로 받은 Secure 쿠키도 저장하고 되돌려 보냈다(실측). 그래서 이 실습이 평문으로도 돈다.

세션 ID 는 OWASP 기준으로 암호학적 난수 생성기가 만든 64비트 이상의 엔트로피를 가져야 한다. 파이썬이라면 secrets.token_urlsafe(32) 가 256비트다. 로그아웃은 두 가지를 모두 해야 한다. 서버 쪽 세션을 무효화하고, 쿠키는 Max-Age=0 으로 지운다. 이때 삭제 쿠키도 접두 규칙을 통과해야 한다. curl 8.5 에서 재 보니 Secure 가 빠진 __Host-sid=; Max-Age=0 은 무시되고 옛 쿠키가 저장소에 그대로 남았다.

현장에서 만나는 모습

로그아웃 버튼이 쿠키만 지우는 앱이 흔하다. 화면에서는 로그아웃된 것처럼 보이지만, 프록시 로그나 누군가 공유한 HAR 파일에 남은 값으로 요청하면 여전히 로그인 상태다. 서버가 기억을 지우지 않았으니 그 값은 아직 열쇠다.

또 하나는 "스테이징에서만 로그인이 안 된다" 다. 스테이징이 평문 HTTP 인데 쿠키에 Secure 가 붙어 있거나, __Host- 쿠키에 누군가 Domain 을 덧붙인 경우다. 서버 로그에는 Set-Cookie 가 정상으로 나가고 브라우저는 조용히 버리니, 네트워크 탭에서 응답이 아니라 저장된 쿠키 목록을 봐야 원인이 보인다.

다음 실습에서 할 것

127.0.0.1:8301 에 로그인 서버를 세우고 __Host-sid 세션 쿠키를 발급한다. 속성을 응답 헤더에서 확인하고, curl 쿠키 저장소에 #HttpOnly_ 표시로 남는지 본다. 로그아웃에서는 서버 세션을 지우지 않는 흔한 실수를 반례로 삼아, 복사해 둔 옛 쿠키가 401 이 되는지 직접 재현한다. 마지막으로 여러 Set-Cookie 줄을 접두 규칙으로 판정한다.