본문 바로가기

쿠키와 세션

상태를 기억하지 않는 HTTP 위에서 쿠키가 상태를 이어 가는 방법과, 브라우저가 쿠키를 보낼지 정하는 도메인, 경로, 보안 조건을 코드로 따라갑니다. SameSite와 CSRF 방어, 안전한 세션 쿠키의 설정, 서버 세션과 서명 토큰의 차이, 서드파티 쿠키와 프라이버시를 함께 정리합니다.

HTTP는 상태를 기억하지 않는(stateless) 프로토콜입니다. 요청 하나하나가 그 자체로 이해될 수 있어야 하고, 서버는 앞의 요청을 기억해 두지 않아도 됩니다. 이 성질 덕분에 서버를 여러 대로 늘리기 쉽지만, 로그인한 사용자나 장바구니처럼 앞의 요청에서 생긴 상태가 필요한 웹 애플리케이션에는 상태를 이어 줄 장치가 따로 필요합니다. 그 장치가 쿠키(cookie)입니다. 이 글에서는 RFC 6265에 정의된 쿠키가 어떻게 오가는지, 언제 보내지는지, 로그인 상태를 어떻게 안전하게 이어 가는지를 살펴봅니다.

쿠키는 이름과 값을 왕복시킨다

서버는 응답의 Set-Cookie 헤더로 쿠키의 이름과 값, 그리고 그 쿠키를 언제 어디로 보낼지 정하는 속성을 브라우저에 줍니다. 브라우저는 쿠키를 저장해 두었다가, 조건에 맞는 요청마다 Cookie 헤더에 이름과 값을 실어 보냅니다. 로그인에 쓰는 쿠키에는 보통 사용자의 정보가 아니라 예측할 수 없는 세션 식별자(session ID)만 담고, 그 식별자가 가리키는 로그인 상태는 서버의 세션 저장소에 둡니다.

쿠키 식별자로 서버 세션을 찾는 왕복

서버가 정상 로그인 요청을 받습니다. 서버가 세션 상태와 권한을 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP · 서버 세션

브라우저는 식별자를 보내고 서버는 세션 상태를 조회한다

성공한 로그인에서 서버가 세션을 저장하고 sid 쿠키를 설정합니다. 이후 전송 조건에 맞는 쿠키가 오면 저장된 상태와 요청 권한을 확인한 뒤 응답합니다.

쿠키 식별자로 서버 세션을 찾는 왕복성공한 로그인에서 서버는 세션 상태를 저장하고 브라우저에는 sid 쿠키를 보냅니다. 이후 브라우저가 전송 조건에 맞는 sid를 보내면 서버가 저장소를 조회하고 요청 권한을 확인합니다.로그인 요청세션 저장저장 완료Set-Cookie: sidCookie: sidsid 조회사용자 상태권한 확인 후 응답브라우저앱 서버세션 저장소쿠키 식별자로 서버 세션을 찾는 왕복왼쪽 브라우저, 가운데 앱 서버, 오른쪽 세션 저장소의 시간축입니다. 위에서 아래로 로그인, 저장, 저장 완료, Set-Cookie, 다음 Cookie 요청, sid 조회, 사용자 상태 반환, 권한 확인 후 응답의 같은 여덟 메시지를 읽습니다. 세션 상태는 브라우저 쿠키가 아니라 서버 저장소에 있습니다.로그인요청세션저장저장완료Set-Cookie:sidCookie:sidsid조회사용자상태권한확인 후응답브라우저앱서버세션저장소

위에서 아래로 시간 순서가 진행하며 간격은 실제 시간이 아닙니다. 실선은 요청, 가로 점선은 반환입니다. 정상 로그인과 유효한 세션의 예시입니다. Cookie에는 세션 식별자를 싣고 사용자 상태는 저장소에 둡니다. 실제 전송 조건·만료·권한 검사는 별도로 만족해야 하며, 아래 방문 횟수 데모를 실행한 흐름은 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 로그인부터 조회와 응답까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
로그인

서버가 정상 로그인 요청을 받습니다.

  • 쿠키·서버 세션 1: browser→app
  • 인증 성공을 가정한 경로로 진행
세션과 쿠키

서버에 상태를 저장한 뒤 식별자를 보냅니다.

  • 쿠키·서버 세션 1: store user state
  • 쿠키·서버 세션 2: Set-Cookie sid
  • Set-Cookie로 sid 저장; 사용자 상태 자체는 서버에 있음
다음 요청

브라우저가 전송 조건에 맞는 쿠키를 보냅니다.

  • 쿠키·서버 세션 1: Cookie sid
  • 쿠키·서버 세션 2: Domain/Path/Secure/SameSite/만료
  • Domain·Path·Secure·SameSite·만료 적용; 보유만으로 전송을 보장하지 않음
조회와 응답

서버가 세션 상태와 권한을 확인합니다.

  • 쿠키·서버 세션 1: sid 조회→user state
  • 쿠키·서버 세션 2: 권한 확인 후 응답
  • 유효 세션의 성공 응답; 쿠키 식별자 자체가 신원 증명은 아님

쿠키 식별자로 서버 세션을 찾는 왕복

서버가 정상 로그인 요청을 받습니다. 서버가 세션 상태와 권한을 확인합니다. 각 단계의 값과 전환 이유를 비교합니다.

HTTP · 서버 세션

브라우저는 식별자를 보내고 서버는 세션 상태를 조회한다

성공한 로그인에서 서버가 세션을 저장하고 sid 쿠키를 설정합니다. 이후 전송 조건에 맞는 쿠키가 오면 저장된 상태와 요청 권한을 확인한 뒤 응답합니다.

쿠키 식별자로 서버 세션을 찾는 왕복성공한 로그인에서 서버는 세션 상태를 저장하고 브라우저에는 sid 쿠키를 보냅니다. 이후 브라우저가 전송 조건에 맞는 sid를 보내면 서버가 저장소를 조회하고 요청 권한을 확인합니다.로그인 요청세션 저장저장 완료Set-Cookie: sidCookie: sidsid 조회사용자 상태권한 확인 후 응답브라우저앱 서버세션 저장소쿠키 식별자로 서버 세션을 찾는 왕복왼쪽 브라우저, 가운데 앱 서버, 오른쪽 세션 저장소의 시간축입니다. 위에서 아래로 로그인, 저장, 저장 완료, Set-Cookie, 다음 Cookie 요청, sid 조회, 사용자 상태 반환, 권한 확인 후 응답의 같은 여덟 메시지를 읽습니다. 세션 상태는 브라우저 쿠키가 아니라 서버 저장소에 있습니다.로그인요청세션저장저장완료Set-Cookie:sidCookie:sidsid조회사용자상태권한확인 후응답브라우저앱서버세션저장소

위에서 아래로 시간 순서가 진행하며 간격은 실제 시간이 아닙니다. 실선은 요청, 가로 점선은 반환입니다. 정상 로그인과 유효한 세션의 예시입니다. Cookie에는 세션 식별자를 싣고 사용자 상태는 저장소에 둡니다. 실제 전송 조건·만료·권한 검사는 별도로 만족해야 하며, 아래 방문 횟수 데모를 실행한 흐름은 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 로그인부터 조회와 응답까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
로그인

서버가 정상 로그인 요청을 받습니다.

  • 쿠키·서버 세션 1: browser→app
  • 인증 성공을 가정한 경로로 진행
세션과 쿠키

서버에 상태를 저장한 뒤 식별자를 보냅니다.

  • 쿠키·서버 세션 1: store user state
  • 쿠키·서버 세션 2: Set-Cookie sid
  • Set-Cookie로 sid 저장; 사용자 상태 자체는 서버에 있음
다음 요청

브라우저가 전송 조건에 맞는 쿠키를 보냅니다.

  • 쿠키·서버 세션 1: Cookie sid
  • 쿠키·서버 세션 2: Domain/Path/Secure/SameSite/만료
  • Domain·Path·Secure·SameSite·만료 적용; 보유만으로 전송을 보장하지 않음
조회와 응답

서버가 세션 상태와 권한을 확인합니다.

  • 쿠키·서버 세션 1: sid 조회→user state
  • 쿠키·서버 세션 2: 권한 확인 후 응답
  • 유효 세션의 성공 응답; 쿠키 식별자 자체가 신원 증명은 아님

다음 코드는 Python의 http.cookies로 Set-Cookie 헤더를 만들고, 브라우저가 보낸 Cookie 헤더를 읽습니다.

from http.cookies import SimpleCookie

cookie = SimpleCookie()
cookie["sid"] = "R7xQ2"
cookie["sid"]["path"] = "/api"
cookie["sid"]["max-age"] = 3600
cookie["sid"]["secure"] = True
cookie["sid"]["httponly"] = True
cookie["sid"]["samesite"] = "Lax"
print(cookie.output())                               # 서버가 보내는 헤더

received = SimpleCookie("sid=R7xQ2; theme=dark")     # 브라우저가 보낸 헤더
print({name: morsel.value for name, morsel in received.items()})
Set-Cookie: sid=R7xQ2; HttpOnly; Max-Age=3600; Path=/api; SameSite=Lax; Secure
{'sid': 'R7xQ2', 'theme': 'dark'}

서버가 보낸 Set-Cookie에는 속성이 여럿 붙어 있지만, 브라우저가 돌려보내는 Cookie에는 이름과 값만 있습니다. 속성은 브라우저가 쿠키를 보낼지 판단하는 데만 쓰이므로, 서버는 받은 쿠키가 어떤 속성으로 설정되었는지 알 수 없습니다. 쿠키는 클라이언트가 마음대로 고쳐 보낼 수 있다는 점도 기억해야 합니다. 그래서 쿠키의 값은 서버가 확인할 수 있는 식별자로 두고, 권한 같은 중요한 정보를 그대로 담지 않습니다.

쿠키가 실리는 조건

속성예뜻
Domainexample.com이 도메인과 그 하위 도메인으로 보낸다. 생략하면 쿠키를 설정한 호스트에만 보낸다
Path/api이 경로와 그 아래 경로로의 요청에만 보낸다
Expires, Max-Age날짜, 3600만료 시각이나 남은 초. 둘 다 있으면 Max-Age가 우선이고, 없으면 브라우저를 닫을 때까지
Secure플래그HTTPS 요청에만 보낸다
HttpOnly플래그페이지의 스크립트가 document.cookie로 읽지 못하게 한다
SameSiteStrict, Lax, None다른 사이트에서 시작된 요청에도 보낼지 정한다

Domain을 생략한 쿠키는 그 쿠키를 설정한 호스트에만 가는 host-only 쿠키가 됩니다. Path는 단순히 문자열이 그 값으로 시작하는지를 보지 않고, 경로의 구분인 /까지 맞아야 합니다(RFC 6265 §5.1.4). 다음 코드는 https://api.example.com에서 Path=/api; Secure로 설정된 host-only 쿠키가 어느 요청에 실리는지 판단합니다.

def path_match(request_path, cookie_path):
    if request_path == cookie_path:
        return True
    if request_path.startswith(cookie_path):
        return cookie_path.endswith("/") or request_path[len(cookie_path)] == "/"
    return False

def sends(cookie, host, path, https):
    if cookie["host_only"]:
        host_ok = host == cookie["host"]
    else:
        host_ok = host == cookie["domain"] or host.endswith("." + cookie["domain"])
    return host_ok and path_match(path, cookie["path"]) and (https or not cookie["secure"])

sid = {"host": "api.example.com", "host_only": True, "domain": None, "path": "/api", "secure": True}
for host, path, https in [
    ("api.example.com", "/api/cart", True),
    ("api.example.com", "/apix", True),
    ("www.example.com", "/api/cart", True),
    ("api.example.com", "/api/cart", False),
]:
    scheme = "https" if https else "http"
    print(f"{scheme}://{host}{path}: {'보낸다' if sends(sid, host, path, https) else '보내지 않는다'}")
https://api.example.com/api/cart: 보낸다
https://api.example.com/apix: 보내지 않는다
https://www.example.com/api/cart: 보내지 않는다
http://api.example.com/api/cart: 보내지 않는다

/apix는 문자열로는 /api로 시작하지만 다음 글자가 /가 아니므로 맞지 않습니다. www.example.com은 같은 도메인의 다른 호스트여서 host-only 쿠키를 받지 못하고, http로 가는 요청에는 Secure 쿠키가 실리지 않습니다. 쿠키가 있는데 서버가 받지 못한다면, 값이 아니라 이런 조건부터 확인합니다. 실제 브라우저는 여기에 SameSite와 만료, 사용자의 설정까지 더해 판단합니다. 개발자 도구의 애플리케이션(Application) 탭에서는 저장된 쿠키마다의 속성을, 네트워크(Network) 탭에서 요청을 고르면 나오는 쿠키(Cookies) 탭에서는 그 요청에 실린 쿠키를 확인할 수 있습니다.

SameSite와 CSRF

CSRF(cross-site request forgery)는 사용자가 공격자의 사이트를 여는 동안, 그 페이지가 사용자의 브라우저로 피해 사이트에 요청을 보내게 하는 공격입니다. 쿠키로 로그인을 유지하는 사이트라면 브라우저가 그 요청에도 쿠키를 자동으로 실으므로, 서버는 사용자가 직접 보낸 요청으로 착각하고 송금이나 비밀번호 변경을 처리할 수 있습니다.

SameSite는 다른 사이트에서 시작된 요청에 쿠키를 실을지 정합니다. 여기서 같은 사이트란 www.example.com과 api.example.com처럼 등록 가능한 도메인(example.com)과 스킴이 같은 경우를 말합니다. Strict는 다른 사이트에서 시작된 요청에는 쿠키를 싣지 않고, Lax는 링크를 눌러 페이지를 옮기는 GET 이동에만 싣고, None은 모든 요청에 싣되 Secure가 함께 있어야 합니다. Chrome과 Edge는 SameSite를 적지 않은 쿠키를 Lax처럼 다루지만, Firefox와 Safari는 기본으로 그렇게 하지 않으므로 브라우저마다 같은 동작을 기대하려면 속성을 직접 적어 둡니다.

Lax는 다른 사이트의 양식이 몰래 보내는 POST를 막아 주지만, 그것만으로 CSRF가 끝나지는 않습니다. GET으로 상태를 바꾸는 엔드포인트가 있다면 링크 한 번으로 공격당하고, 같은 사이트로 취급되는 하위 도메인 하나가 뚫리면 그곳에서 보낸 요청은 막지 못합니다. 그래서 상태를 바꾸는 요청에는 페이지마다 발급한 CSRF 토큰을 함께 보내게 하거나, 브라우저가 붙이는 Origin 헤더가 내 사이트인지 확인하는 방어를 함께 둡니다.

더 깊이: SameSite를 정한 문서와 공개 접미사 목록

SameSite와 뒤에서 볼 __Host- 접두사는 2011년의 RFC 6265에는 없습니다. 둘 다 그 개정판인 RFC 6265bis 초안에 정의되어 있으며, RFC 6265를 대체할 이 초안은 2026년 10월 기준으로 승인을 마치고 RFC 편집자의 출판 대기열에 있습니다. 브라우저들은 RFC 번호가 나오기 전부터 초안을 따라 구현해 왔으므로, 쿠키의 동작을 확인할 때는 RFC 6265보다 이 초안과 브라우저의 문서를 봐야 합니다.

같은 사이트인지를 가르는 등록 가능한 도메인(registrable domain)은 초안에서 공개 접미사(public suffix)에 그 왼쪽 label 하나를 더한 것으로 정의됩니다. 공개 접미사 목록의 정의로 공개 접미사는 com이나 co.kr처럼 누구나 그 아래에 이름을 등록할 수 있는 접미사이고, 브라우저는 이 목록으로 높은 단계의 접미사 전체에 걸리는 쿠키를 막습니다. 그래서 a.co.kr과 b.co.kr은 서로 다른 사이트이고, 많은 브라우저는 Domain=co.kr처럼 공개 접미사에 쿠키를 거는 설정을 받아들이지 않습니다. 목록에는 도메인 주인이 직접 올리는 비공개(PRIVATE) 구역도 있어서, 하위 도메인을 여러 사용자에게 나누어 주는 서비스가 자기 도메인을 올려 두면 사용자들의 하위 도메인도 서로 다른 사이트로 취급됩니다.

안전한 세션 쿠키

로그인 세션의 쿠키는 다음처럼 범위를 최대한 좁혀 설정하는 것이 출발점입니다. 아래 응답 헤더에서는 이름 앞의 __Host-, Domain 속성이 없다는 점, 끝에 붙은 Secure, HttpOnly, SameSite=Lax를 보면 됩니다. Max-Age=86400은 쿠키를 하루(86400초) 동안 유지하라는 뜻이고, K8xVq...는 실제 식별자를 줄여 적은 자리입니다.

Set-Cookie: __Host-session=K8xVq...; Path=/; Max-Age=86400; Secure; HttpOnly; SameSite=Lax

__Host-로 시작하는 이름은 브라우저가 Secure가 있고, Path=/이며, Domain이 없을 때만 받아들이는 특별한 접두사입니다. 그래서 이 쿠키는 HTTPS로 설정된 host-only 쿠키임이 보장되어, 하위 도메인이 같은 이름의 쿠키를 덮어쓰는 공격을 막을 수 있습니다. HttpOnly는 공격자가 페이지에 자기 스크립트를 끼워 넣어 실행하는 XSS(cross-site scripting, 웹 보안 기초) 취약점이 있더라도 스크립트가 세션 식별자를 읽어 가지 못하게 해 피해를 줄입니다.

쿠키의 속성이 할 수 있는 일은 여기까지이고, 나머지는 서버의 몫입니다. 세션 식별자는 추측할 수 없는 난수여야 하고, 로그인할 때마다 새로 발급해야 하며, 로그아웃하면 서버의 세션 저장소에서 지워야 합니다. 다음 코드는 이 흐름을 흉내 냅니다.

import secrets

sessions = {}

def login(user):
    sid = secrets.token_urlsafe(32)                  # 32바이트 난수로 만든 식별자
    sessions[sid] = {"user": user, "role": "member"}
    return sid

def handle(sid):
    state = sessions.get(sid)
    return f"{state['user']}님 환영합니다" if state else "401 로그인이 필요합니다"

sid = login("kim")
print(len(sid), handle(sid))
del sessions[sid]                                    # 서버에서 로그아웃: 세션을 지운다
print(handle(sid))                                   # 브라우저에 쿠키가 남아 있어도 통하지 않는다
43 kim님 환영합니다
401 로그인이 필요합니다

32바이트의 난수는 base64url로 43글자가 되며, 실행할 때마다 값이 달라지므로 길이만 출력했습니다. 서버가 세션을 지운 뒤에는 브라우저가 같은 쿠키를 보내도 로그인 상태로 인정되지 않습니다. 브라우저의 쿠키를 지우는 것과 서버의 세션을 지우는 것은 서로 다른 일이어서, 로그아웃은 반드시 서버의 세션을 지우는 것으로 처리해야 합니다.

서버 세션과 서명 토큰

서버 세션은 상태를 서버에 두므로, 서버가 여러 대라면 모든 서버가 같은 세션 저장소를 보거나 한 사용자를 같은 서버로 보내야 합니다. 이를 피하려고 상태 자체를 서명해서 클라이언트에 맡기는 방식이 토큰 인증이며, 대표가 JWT(JSON web token, RFC 7519)입니다. JWT는 헤더, 내용(claim), 서명을 각각 base64url로 바꿔 점으로 이은 문자열이고, 서버는 서명만 확인하면 저장소를 보지 않고도 내용을 믿을 수 있습니다.

HTTP · 인증 상태

서명 토큰도 즉시 회수에는 별도 상태 관리가 필요하다

일반적인 서버 세션 ID와 서명 JWT를 비교합니다. 토큰을 쿠키에 넣을 수도 있으므로 운반 방식과 토큰 형식은 별개이며, 두 방식 모두 서버의 인증·권한 검증이 필요합니다.

세션 저장소와 서명 토큰의 상태 관리
비교 기준서버 세션서명 JWT
클라이언트 값보통 세션 ID서명된 claim 묶음
서버 상태세션 저장소 필요access token 검증은 무상태 가능
강제 로그아웃세션 삭제를 후속 조회에 반영 가능만료 전 무효화에는 별도 장치 필요
권한 변경저장소의 세션/사용자 상태를 갱신기존 토큰이 낡은 권한을 들 수 있음
네트워크 크기보통 작은 식별자claim과 서명 때문에 보통 더 큼
보안 초점세션 ID 난수성, 저장소 보호, 쿠키 속성서명 알고리즘, 키 관리, iss/aud/exp 검증
클라이언트 값
서버 세션: 보통 세션 ID
서명 JWT: 서명된 claim 묶음
서버 상태
서버 세션: 세션 저장소 필요
서명 JWT: access token 검증은 무상태 가능
강제 로그아웃
서버 세션: 세션 삭제를 후속 조회에 반영 가능
서명 JWT: 만료 전 무효화에는 별도 장치 필요
권한 변경
서버 세션: 저장소의 세션/사용자 상태를 갱신
서명 JWT: 기존 토큰이 낡은 권한을 들 수 있음
네트워크 크기
서버 세션: 보통 작은 식별자
서명 JWT: claim과 서명 때문에 보통 더 큼
보안 초점
서버 세션: 세션 ID 난수성, 저장소 보호, 쿠키 속성
서명 JWT: 서명 알고리즘, 키 관리, iss/aud/exp 검증

세션 삭제·권한 변경의 반영 시점은 저장소 복제와 캐시 정책도 따릅니다. JWT 만료 전 회수에는 차단 목록·token version 등 별도 장치를 둘 수 있습니다.

서명이 내용을 숨겨 주지는 않습니다. 다음 코드는 널리 알려진 예제 토큰을 키 없이 풀어 봅니다.

import base64
import json

token = (
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
    "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ."
    "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)

def b64url_decode(part):
    return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))    # 빠진 = 를 채운다

header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header)))
print(json.loads(b64url_decode(payload)))
print(f"서명 {len(b64url_decode(signature))}바이트")
{'alg': 'HS256', 'typ': 'JWT'}
{'sub': '1234567890', 'name': 'John Doe', 'iat': 1516239022}
서명 32바이트

토큰을 가진 사람은 누구나 내용을 읽을 수 있으므로, JWT에 비밀번호나 개인정보를 넣으면 안 됩니다. 반대로 내용을 고치면 서명이 맞지 않게 되므로, 서버는 서명과 함께 알고리즘, 발급자, 대상, 만료 시각을 반드시 검증해야 합니다. 내용을 숨겨야 한다면 서명이 아닌 암호화 형식(JWE)을 따로 써야 합니다.

JWT의 약점은 회수입니다. 서버 세션은 저장소에서 지우는 순간 효력이 사라지지만, 서명된 토큰은 만료 시각까지 유효하므로 사용자를 강제로 로그아웃시키거나 권한을 빼앗아도 이미 발급한 토큰은 예전 권한을 그대로 들고 있습니다. 그래서 토큰의 만료를 몇 분으로 짧게 두고 따로 발급한 갱신 토큰(refresh token)으로 새로 받게 하거나, 회수한 토큰의 목록을 서버가 관리하는데, 이렇게 되면 결국 서버에 상태가 다시 생깁니다. 토큰을 어디에 담아 나르느냐는 별개의 문제로, HttpOnly 쿠키에 담으면 스크립트가 읽을 수 없는 대신 CSRF를 막아야 하고, 스크립트가 다루는 저장소에 두면 XSS에 노출됩니다.

서드파티 쿠키와 프라이버시

서드파티 쿠키(third-party cookie)는 지금 보고 있는 사이트가 아닌 다른 사이트의 쿠키입니다. 쇼핑몰과 뉴스 사이트가 같은 광고 회사의 이미지나 스크립트를 넣어 두면, 광고 회사는 두 사이트를 방문한 사람이 같은 브라우저라는 것을 자기 쿠키로 알아낼 수 있습니다. 여러 사이트에 걸친 방문 기록이 한곳에 모이는 것입니다.

그래서 브라우저들은 서드파티 쿠키를 제한해 왔습니다. Safari와 Firefox는 기본으로 막거나 사이트마다 따로 보관하고, Chrome도 사용자가 설정에서 막을 수 있습니다. 서드파티 쿠키가 꼭 필요한 삽입형 서비스를 위해서는 Partitioned 속성으로 쿠키를 지금 보고 있는 최상위 사이트마다 나누어 저장하는 방식도 나왔습니다. 다른 사이트에 삽입되는 위젯이나 결제 창을 만든다면 서드파티 쿠키가 언제든 막힐 수 있다고 보고 설계해야 합니다.

정리

  • HTTP는 상태를 기억하지 않으므로 쿠키로 상태를 잇고, 로그인 쿠키에는 예측할 수 없는 세션 식별자만 담아 상태는 서버에 둡니다.
  • 서버는 Set-Cookie로 이름, 값, 속성을 보내고, 브라우저는 조건에 맞는 요청에 Cookie로 이름과 값만 돌려보냅니다.
  • 브라우저는 도메인, /까지 맞춰 보는 경로, Secure, SameSite, 만료를 모두 따져 쿠키를 보낼지 정합니다.
  • SameSite=Lax는 다른 사이트의 POST를 막지만 CSRF 방어를 대신하지 못하므로 CSRF 토큰이나 Origin 확인을 함께 씁니다.
  • 세션 쿠키는 __Host- 접두사와 Secure, HttpOnly, SameSite로 범위를 좁히고, 로그아웃은 서버의 세션을 지워서 처리합니다.
  • JWT는 서명으로 위조를 막을 뿐 내용을 숨기지 않으며, 만료 전에 회수하려면 짧은 만료와 갱신 토큰 같은 상태 관리가 필요합니다.

연습 문제

1. 경로를 넓히고 세션을 지우면

본문의 sid 쿠키에서 Path만 /로 바꾸었습니다. https://api.example.com/apix와 https://www.example.com/api/cart로 가는 요청에는 각각 쿠키가 실리나요? 또 api.example.com으로 쿠키가 실려 갔지만 서버의 세션 저장소에서 그 식별자가 이미 지워졌다면, 쿠키가 왔으니 로그인 상태로 보여 줘도 되나요?

해설 보기

Path=/이면 같은 호스트의 모든 경로가 맞으므로 /apix에는 실립니다. 하지만 Domain을 생략한 host-only 쿠키라는 점은 그대로이므로 www.example.com에는 여전히 실리지 않습니다. 세션이 지워졌다면 쿠키가 와도 로그인 상태가 아닙니다. 쿠키는 서버의 세션을 찾는 열쇠일 뿐이므로 서버가 그 식별자로 살아 있는 세션을 찾아야 하고, 찾았더라도 이 요청을 할 권한이 있는지 다시 확인해야 합니다.

2. Domain을 적은 쿠키

https://api.example.com이 Domain=example.com; Path=/; Secure로 쿠키를 설정했습니다. 본문의 sends로 판단하면 https://www.example.com/, https://example.com/, https://evil-example.com/으로 가는 요청 가운데 어디에 쿠키가 실리나요? 로그인 쿠키에 Domain을 적을 때 생기는 위험도 생각해 보세요.

해설 보기

host_only가 아니므로 호스트가 example.com과 같거나 .example.com으로 끝나야 합니다. www.example.com과 example.com에는 실리고, evil-example.com은 .example.com으로 끝나지 않으므로 실리지 않습니다. 점을 붙여 비교하는 것이 이 차이를 만듭니다. Domain을 적으면 모든 하위 도메인으로 쿠키가 가므로, 직원 블로그나 오래된 마케팅 페이지처럼 관리가 허술한 하위 도메인 하나가 뚫려도 로그인 쿠키가 새어 나갈 수 있습니다. 로그인 쿠키는 Domain을 적지 않은 host-only, 가능하면 __Host- 접두사로 두는 것이 안전합니다.

3. 토큰의 내용을 고치면

본문의 JWT를 받은 사용자가 내용 부분을 풀어 "role": "admin"을 추가하고 다시 base64url로 바꿔 보냈습니다. 서버가 서명을 제대로 검증한다면 어떻게 되나요? 서버가 이 사용자의 권한을 방금 빼앗았는데, 사용자가 원래의 토큰을 계속 보낸다면 어떻게 되나요?

해설 보기

서명은 헤더와 원래의 내용으로 계산한 값이므로, 내용을 바꾸면 서명이 맞지 않아 서버가 토큰을 거부합니다. 키를 모르는 사용자는 바뀐 내용에 맞는 서명을 만들 수 없습니다. 서버가 서명을 검증하지 않거나 alg 값을 그대로 믿어 검증을 건너뛰는 실수를 하면 이 공격이 통하므로, 허용할 알고리즘을 서버가 정해 두어야 합니다. 원래의 토큰은 서명이 맞고 만료 전이라면 계속 유효하므로, 권한을 빼앗은 뒤에도 만료 시각까지는 예전 권한으로 통합니다. 이를 막으려면 토큰의 만료를 짧게 두거나, 회수한 토큰과 사용자의 권한 버전을 서버에서 확인해야 합니다.

다음 글 캐시와 CORS에서는 응답을 저장해 다시 쓰는 HTTP 캐시의 규칙과, 다른 출처의 응답을 읽을 수 있게 허락하는 CORS를 살펴봅니다.