세션과 토큰을 사람이 말하듯이

1편에서 브라우저는 은행이 진짜라는 걸 확인했고, 둘만 아는 키로 대화할 통로를 만들었다. 그런데 은행은 아직 내가 누군지 모른다. 이번 편은 은행이 나를 확인하고, 확인한 걸 계속 기억하는 방법이다.

고친 점 (처음에 헷갈렸던 이해) 쿠키 (브라우저가 들고 있는 메모)
1 · 문제

은행은 아직 내가 누군지 모른다

TLS가 끝나면 브라우저와 은행 사이에 안전한 통로가 생긴다. 하지만 은행이 아는 건 "누군가와 안전한 통로가 생겼다"까지다. 그 누군가가 김상필인지, 내 비밀번호를 훔친 다른 사람인지는 모른다.

1편이 "이 사이트가 진짜일까?"였다면, 이번 편은 "이 사람이 진짜일까?"다.

누가 누구를 확인하나무엇으로
1편브라우저가 은행을인증서와 은행 서명
2편은행이 나를비밀번호, 그다음은 세션이나 토큰

확인은 두 단계로 나뉜다. 처음 한 번 "너 누구야"를 확인하는 단계(로그인), 그리고 그 뒤로 요청이 올 때마다 "아까 그 사람 맞다"를 이어 가는 단계(세션이나 토큰)다.

2 · 처음 확인

로그인, 비밀번호를 통로 안으로 보낸다

  1. 아이디와 비밀번호를 1편에서 만든 통로 안으로 보낸다. 엿보는 사람에게는 잠긴 덩어리로만 보인다.
  2. 은행은 비밀번호 원문을 저장해 두지 않는다. 1편 4장에서 본 것처럼, 솔트를 섞어 느린 해시로 계산한 값만 저장해 둔다.
  3. 들어온 비밀번호를 같은 솔트, 같은 방식으로 계산해서 저장해 둔 값과 같으면 통과다.

Django는 비밀번호를 이런 모양으로 저장한다. 방식, 반복 횟수, 솔트, 해시값이 $로 나뉘어 한 줄에 들어간다.

pbkdf2_sha256$(반복 횟수, 수십만~백만)$(사람마다 다른 솔트)$(해시값)

헷갈리기 쉬운 점이 하나 있다. 비밀번호는 통로 안에서는 원문 그대로 간다. 해시는 은행이 저장하고 비교할 때 하는 일이다. 가는 길은 TLS가, 저장된 값은 해시가 지킨다.

3 · 문제

웹은 요청마다 처음 보는 사람처럼 대한다

웹(HTTP)은 요청 하나하나를 따로 처리한다. 방금 로그인한 사람이 다음 페이지를 열어도, 서버 입장에서는 처음 보는 요청이다. 그렇다고 페이지를 열 때마다 비밀번호를 칠 수는 없다.

그래서 로그인에 성공하면 "나 아까 로그인한 그 사람이야"를 보여 줄 무언가를 받아 두고, 요청할 때마다 그걸 같이 보낸다. 그 무언가에 무엇을 담느냐에 따라 두 가지로 나뉜다.

세션
찜질방 팔찌

팔찌에는 번호만 있다. 누가 무엇을 샀는지는 카운터 장부에 있다. 서버가 기억한다.

토큰
놀이공원 자유이용권

손목띠에 내용이 다 인쇄돼 있고 매표소 도장이 찍혀 있다. 들고 다니는 쪽이 증명한다.

4 · 방법 하나

세션은 찜질방 팔찌와 카운터 장부

찜질방에 들어갈 때 계산을 하면 번호가 적힌 팔찌를 준다. 팔찌에는 번호만 있고 이름은 없다. 매점에서 식혜를 사면 팔찌 번호만 보여 주고, 카운터 장부에 "37번 식혜 하나"가 적힌다. 나갈 때 정산하면 37번은 장부에서 지워진다.

찜질방세션
입장할 때 계산로그인
팔찌 번호세션 ID (아무 의미 없는 무작위 문자열)
카운터 장부서버의 세션 저장소 (Django는 django_session 테이블)
매점에서 팔찌 보여 주기요청할 때마다 쿠키로 세션 ID를 같이 보냄
나갈 때 정산로그아웃, 장부에서 삭제
떨어진 팔찌를 주운 사람세션 ID를 훔친 사람

팔찌 번호만 봐서는 누구 것인지 알 수 없다. 의미는 전부 카운터 장부에 있다. 그래서 세션을 "서버가 기억하는 방식"이라고 한다. 거꾸로 말하면, 장부에서 지우는 순간 그 팔찌는 바로 쓸모가 없어진다.

브라우저

내가 가진 쿠키
로그인을 눌러 시작해 보자.

은행 서버의 장부

세션 ID누구만료
6 · 위험

통로가 안전한데 세션 ID는 어디서 털릴까?

고친 점

"번호표를 훔치려면 통로 안에서 훔쳐야 되는데, 그걸 못 하게 키를 어렵게 만든 거잖아"

통로 안에서 훔치는 건 TLS 덕분에 거의 불가능하다. 여기까지는 맞다. 그런데 세션 ID는 통로 안이 아니라 통로의 양 끝, 그리고 통로가 없는 순간에 털린다.

TLS는 현금 수송차다. 운송 중에는 안전하다. 하지만 집에 도둑이 드는 것(브라우저 안), 수송차 없이 돈을 들고 걷는 순간(http), 누가 나인 척 심부름을 보내는 것(CSRF)은 수송차가 막을 수 없다. 쿠키 옵션은 집 보안이다.

옵션을 켜고 끄면서 공격해 보기

위 4장과 같은 브라우저, 같은 장부다. 먼저 로그인한 상태에서 해 보자.
옵션을 모두 끈 상태로 로그인하고 공격해 본 뒤, 하나씩 켜 보자.
장부의 세션 ID누구만료
공격어떻게막는 것
XSS게시글 같은 곳에 심어 둔 스크립트가 내 브라우저에서 실행되어, 이미 풀린 쿠키를 읽어 간다HttpOnly, 그리고 스크립트가 못 들어오게 입력값 처리
http 노출http:// 로 접속하게 만들면 암호화 없는 요청에 쿠키가 그대로 실린다Secure, HTTPS 강제
CSRF훔치지 않고 이용한다. 해커 사이트가 내 브라우저로 은행에 요청을 보내면 쿠키가 알아서 같이 간다SameSite, CSRF 토큰
악성코드PC가 감염되면 브라우저 저장소를 통째로 가져간다짧은 세션 만료, 중요한 작업 전 비밀번호 다시 묻기

HttpOnly는 세션 ID를 가져가는 것만 막는다. 스크립트가 내 화면 안에서 요청을 대신 보내는 것까지는 못 막는다. 그래서 스크립트가 아예 못 들어오게 막는 게 먼저다. Django 로그인 폼에 숨어 있는 csrftoken은 CSRF를 한 번 더 막는 장치다. 해커 사이트는 이 값을 알 수 없어서, 쿠키가 같이 가더라도 요청이 거부된다.

7 · 방법 둘

토큰은 놀이공원 자유이용권

놀이공원 매표소에서 표를 사면 손목띠를 채워 준다. 띠에는 "성인, 자유이용, 10월 26일까지"가 인쇄돼 있고, 매표소만 찍을 수 있는 도장이 있다. 놀이기구 직원은 매표소에 전화해서 장부를 확인하지 않는다. 띠에 적힌 내용과 도장만 보고 통과시킨다.

놀이공원토큰 (JWT)
매표소로그인 서버
띠에 인쇄된 내용누구, 권한, 만료 시각
매표소 도장서명 (서버 비밀키로 계산한 값)
놀이기구 직원API 서버, 영상 서버
띠만 보고 통과장부를 보지 않고 서명만 확인
"10월 26일까지"만료 시각 (exp)

세션은 번호만 주고 내용은 장부에 두었다. 토큰은 반대로 내용을 다 적고, 고치지 못하게 서명한다. 1편의 서명, HMAC과 같은 원리다.

생긴 모양

JWT는 점(.)으로 나뉜 세 덩어리다. 아래는 이 페이지가 방금 실제로 계산해서 만든 토큰이다.

머리 (header)서명 방식
내용 (payload)누구, 권한, 만료
서명 (signature)머리.내용을 서버 비밀키로 HMAC-SHA256
주의

머리와 내용은 암호화한 게 아니다. base64url이라는 방식으로 글자만 바꿔 적은 것이라 누구나 되돌려 읽을 수 있다. 위 가운데 칸이 바로 되돌린 결과다. 서명은 못 고치게 할 뿐 못 보게 하지는 않는다. 비밀번호 같은 값은 절대 넣으면 안 된다.

내용을 고쳐서 서버에 내밀어 보기

해커가 된 셈 치고 "viewer"를 "admin"으로 바꿔 보자. 해커는 서버 비밀키가 없으니 서명은 원래 것을 그대로 쓸 수밖에 없다.

서버에 내미는 토큰
8 · 약점

직원 계정을 지금 당장 막아야 한다면?

놀이공원 손님이 띠를 잃어버렸다. 매표소가 모든 놀이기구 직원에게 "이 띠는 무효"라고 일일이 알리기는 어렵다. 직원들은 원래 띠만 보고 통과시키니까. 토큰도 똑같다. 서버에 장부가 없어서 "이 사람 토큰만" 골라서 막을 방법이 없다.

고친 점

"다른 사람들한테도 동일한 서명으로 발급해 줬으니 다시 다 발급해야 하는 거 아닐까?"

반은 맞다. 서명 키를 바꾸면 정말 막힌다. 놀이공원이 도장 모양을 바꾸는 셈이라, 옛 도장이 찍힌 띠는 전부 무효가 된다. 대신 전 직원이 한꺼번에 로그아웃된다. 그래서 최후의 수단이고, 평소에는 아래 다른 방법을 섞어 쓴다.

직원 A를 정지시켜 보기

사람정지 직후 요청15분 뒤 요청
9 · 보완

짧은 토큰과 긴 토큰을 같이 쓴다

토큰이 털리거나 정지해야 할 때 피해를 줄이려면 수명을 짧게 하면 된다. 하지만 15분마다 비밀번호를 다시 치게 할 수는 없다. 그래서 토큰을 두 개 준다.

액세스 토큰리프레시 토큰
수명짧게 (5~15분)길게 (며칠~몇 주)
쓰는 곳매 요청액세스 토큰이 만료됐을 때 새로 받는 용도로만
서버가 기억하나안 함. 서명만 확인함. 목록에 두고 폐기할 수 있음
털리면최대 15분서버가 폐기하면 그때 끝
09:00로그인. 액세스 토큰(15분)과 리프레시 토큰(14일)을 받는다.
09:00 ~ 09:15요청마다 액세스 토큰만 보낸다. 서버는 서명만 확인한다.
09:15액세스 토큰 만료. 리프레시 토큰을 내밀자 서버가 목록을 확인하고 새 액세스 토큰을 준다. 사용자는 모른다.
09:20관리자가 계정 정지. 서버가 리프레시 토큰을 목록에서 지운다.
09:30액세스 토큰 만료. 리프레시를 시도하지만 목록에 없어서 거부. 여기서 끊긴다.

결국 리프레시 토큰 쪽은 장부를 다시 두는 셈이다. 토큰도 "장부가 전혀 없는" 방식은 아니고, 장부를 보는 횟수를 15분에 한 번으로 줄인 방식에 가깝다.

10 · 판단

세션과 토큰, 무엇을 쓸까?

세션토큰 (JWT)
비유찜질방 팔찌와 카운터 장부놀이공원 자유이용권
내용이 있는 곳서버 장부토큰 안
서버가 확인하는 법장부 조회서명 확인
즉시 로그아웃장부에서 지우면 바로만료 전까지 어려움
서버가 여러 대일 때장부를 함께 써야 함서명 키만 있으면 됨
잘 맞는 곳브라우저로 쓰는 웹사이트 하나모바일 앱, 외부에 여는 API, 여러 서비스

브라우저로 쓰는 사이트 하나이고, 권한을 바로 회수해야 하는 관제 시스템이라면 세션이 구조적으로 맞다. "요즘은 JWT"라는 이유만으로 바꾸면 오히려 손해일 수 있다.

WVP에 대 보면

구간방식왜
사이트 로그인Django 세션. sessionid 쿠키가 팔찌, django_session 테이블이 장부, csrftoken이 CSRF 방어사람 계정은 정지하면 바로 막혀야 한다
영상 서버 접속짧게 만료되는 서명 토큰요청이 많아서 매번 장부를 보지 않고 서명만 확인하는 편이 빠르다. 대신 수명을 짧게 둔다

로그인 주소 /login/?next=%2F는 Django의 기본 모양이다. "로그인이 끝나면 원래 가려던 /로 보내 달라"는 뜻이다.

WVP 점검 목록

  • 로그인 후 개발자도구 → Application → Cookies에서 sessionid, csrftoken이 있는지
  • sessionid에 HttpOnly, Secure, SameSite가 켜져 있는지 (Django 설정 SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_SAMESITE)
  • 세션 만료 시간이 관제 업무에 맞는지 (SESSION_COOKIE_AGE)
  • 영상 토큰의 만료 시간이 짧은지, 서명 키가 코드에 그대로 적혀 있지 않은지
11 · 정리

한 장 요약

  1. TLS가 끝나도 은행은 내가 누군지 모른다. 그래서 통로 안에서 로그인한다.
  2. 은행은 비밀번호 원문 대신 솔트를 섞은 느린 해시를 저장한다. 들어온 비밀번호를 같은 방식으로 계산해서 비교한다.
  3. 웹은 요청마다 처음 보는 사람처럼 대한다. 그래서 로그인한 증거를 받아 두고 요청마다 같이 보낸다.
  4. 세션은 번호만 주고 내용은 서버 장부에 둔다. 장부에서 지우면 그 자리에서 끝난다.
  5. 토큰은 내용을 다 적고 서명한다. 장부 없이 빠르게 확인하지만, 만료 전에는 골라서 막기 어렵다. 그래서 짧게 만료시키고 리프레시 토큰으로 이어 간다.
  6. 통로가 안전해도 양 끝에서 털린다. HttpOnly, Secure, SameSite와 CSRF 토큰이 그 끝을 지킨다.

한 문장으로: 세션은 서버가 기억하고, 토큰은 들고 다니는 쪽이 증명한다.