Sessions and Tokens — From the Browser to the Mesh
Logged in, but the session ID never changed
한국어 원문으로 표시합니다.
한 줄 요약
서버 세션은 상태를 서버가 쥐고, 브라우저에는 열쇠(세션 ID)만 준다. 그래서 로그아웃 한 번으로 즉시 무효화할 수 있는 대신, 그 열쇠가 로그인 전후로 바뀌지 않으면 남이 미리 심어 둔 열쇠로 내 로그인에 올라타는 세션 고정(session fixation) 이 성립한다.
왜 이게 필요했나
로그인 폼을 만들면 보통 이렇게 짠다. 방문하면 세션을 하나 만들어 쿠키로 내려주고, 로그인에 성공하면 그 세션에 username 을 채운다. 문제는 로그인 전과 후가 같은 세션 ID 라는 데 있다.
공격자는 자기가 아는 세션 ID 를 피해자 브라우저에 미리 심어 둘 수 있다(예전 서버들이 URL 의 ;jsessionid= 를 받아 준 것, 하위 도메인에서 쿠키를 덮어쓰는 것). 피해자가 그 ID 그대로 로그인하면, 서버는 "이미 있던 그 세션"에 로그인 상태만 얹는다. 공격자는 처음부터 알고 있던 그 ID 로 로그인된 세션을 그대로 쓴다. 비밀번호를 몰라도 남의 로그인 세션을 갖는 것이다.
막는 방법은 OWASP Session Management Cheat Sheet 가 한 문장으로 정리한다 — "The session ID must be renewed or regenerated by the web application after any privilege level change." 익명에서 인증으로 넘어가는 순간이 바로 그 권한 변화다. 로그인에 성공하면 새 ID 를 발급하고 옛 ID 는 버린다. 그러면 공격자가 심어 둔 ID 는 로그인 직후 죽은 열쇠가 된다.
어떻게 동작하나
세션 저장소를 Redis 같은 외부 저장소에 두는 이유는 두 가지다. 첫째, 웹 서버가 여러 대일 때 어느 대로 요청이 가든 같은 세션을 본다(로컬 메모리에 두면 붙었던 서버에만 세션이 있다). 둘째, 서버가 상태를 쥐고 있으니 언제든 지워서 즉시 무효화할 수 있다 — 이것이 서버 세션이 JWT 대비 갖는 결정적 장점이다.
세션 ID 는 추측 불가여야 한다. OWASP 는 "at least 64 bits of entropy" 를 요구한다. 파이썬이라면 secrets.token_urlsafe(32) 가 256비트를 준다. 저장소의 키는 sess:<id>, 값에는 최소한 사용자, 만든 시각, 마지막 활동 시각을 담는다.
만료는 두 종류를 함께 둔다. OWASP 의 구분 그대로다.
idle timeout 마지막 활동 이후 N분간 조용하면 만료. 요청마다 갱신(sliding).
자리를 비운 세션이 영원히 살아 있지 않게 한다.
absolute timeout 세션을 만든 지 M시간이 지나면, 계속 쓰고 있었더라도 만료.
탈취된 세션이 무한정 연장되지 않게 하는 상한이다.
Redis 라면 idle 은 키의 TTL 로 자연스럽게 표현된다 — 요청마다 EXPIRE 로 TTL 을 다시 밀어 주면 sliding 이 된다. 하지만 TTL 만으로는 absolute 를 표현할 수 없다(요청마다 밀리니 영원히 안 죽는다). 그래서 값 안에 만든 시각을 따로 넣고, 매 요청에서 now - created > absolute 면 idle TTL 이 남아 있어도 거절한다.
쿠키 속성은 다음 모듈에서 깊게 보지만, 세션 쿠키의 최소선은 HttpOnly(스크립트가 못 읽게), Secure(HTTPS 로만), SameSite=Lax 이상, 그리고 이름에 __Host- 접두를 붙여 하위 도메인이 덮어쓰지 못하게 하는 것이다.
현장에서 만나는 모습
로그인은 되는데 "가끔 다른 사람 계정으로 로그인된다"는 신고가 드물게 들어온다. 대개 세션 ID 를 재발급하지 않는 코드에서, 공유 PC 나 하위 도메인 XSS 를 통해 남의 세션에 올라탄 경우다. 로그를 봐도 정상 로그인이라 원인이 안 보인다 — 세션 ID 가 로그인 전후로 같다는 사실은 애플리케이션 코드를 봐야만 드러난다.
반대로 서버 세션의 장점이 빛나는 순간도 있다. 계정이 털렸다는 신고가 오면 그 사용자의 세션 키를 저장소에서 지우는 것만으로 모든 기기에서 즉시 로그아웃시킬 수 있다. JWT 였다면 만료까지 기다리거나 차단 목록을 따로 운영해야 한다(뒤 모듈에서 다룬다).
대신 세션 저장소는 로그인 전체의 단일 장애점이 된다. Redis 가 멈추면 모든 사용자가 한꺼번에 로그아웃된 것과 같다. 그래서 운영에서는 복제와 장애 조치를 두고, 저장소에 닿지 못할 때 "일단 로그인된 것으로 치는" 쪽으로 넘어지지 않게(fail-closed) 짠다. 키마다 TTL 을 반드시 거는 것도 같은 이유다 — TTL 없는 세션 키는 로그아웃하지 않고 떠난 사용자 수만큼 쌓여 메모리를 먹고, 언젠가 축출 정책이 살아 있는 세션까지 지우기 시작한다.
다음 실습에서 할 것
Redis 를 세션 저장소로 붙인 로그인 서버를 세우고, 쿠키 속성을 점검하고, 로그인 순간 세션 ID 가 재발급되는지를 실제 요청으로 확인한다. 미리 심어 둔 세션 ID 로 로그인해 보고 그 ID 가 로그인 직후 무효가 되는 것까지 본다. 마지막으로 절대 만료가 지난 세션이 idle TTL 과 무관하게 거절되는지 확인한다.