쿠키 이슈

Dokyoung Lee·2026년 2월 11일

HTTP

목록 보기
1/1
post-thumbnail

dev 서버에서 로그인한 뒤 local로 돌아오면 401이 뜬다. 토큰은 유효하고, DB에도 세션이 있다. 그런데 서버에 있어서는 안 될 토큰이 있다.


배경: 쿠키 기반 JWT 인증의 동작 구조

HTTP는 stateless 프로토콜이다.

[브라우저] ── GET /dashboard ──▶ [서버]   "누구세요?"
[브라우저] ◀── 200 OK ────────── [서버]

[브라우저] ── GET /dashboard ──▶ [서버]   "누구세요?" (다시)

서버가 클라이언트의 상태를 유지하기 위해 RFC 6265(HTTP State Management Mechanism)에서 정의한 메커니즘이 쿠키다.

서버가 Set-Cookie 응답 헤더로 값을 내려보내면, 브라우저는 이를 내부 cookie jar에 저장하고, 이후 매칭되는 요청마다 Cookie 요청 헤더에 자동으로 포함하여 전송한다.

서버 → 브라우저:
  Set-Cookie: access=eyJhbG...; Domain=.example.com; Path=/; Secure; HttpOnly; SameSite=None

브라우저 → 서버 (이후 모든 매칭 요청):
  Cookie: access=eyJhbG...

JWT 토큰 쌍과 검증 흐름

서비스는 JWT access/refresh 토큰 쌍을 HttpOnly 쿠키에 저장한다.

서버 측 검증 흐름:

# 1. 쿠키에서 토큰 추출
access_token = request.COOKIES.get("access")
refresh_token = request.COOKIES.get("refresh")

# 2. refresh 토큰 서명 검증 (SECRET_KEY 기반 HMAC)
refresh = RefreshToken(refresh_token)  # → TokenError if invalid signature or expired

# 3. access-refresh 쌍의 user_id 일치 확인
if AccessToken(access_token, verify=False)["user_id"] != refresh["user_id"]:
    raise MismatchToken

# 4. DB에서 세션 조회
session = Session.objects.get(Q(mobile_token=access_token) | Q(web_token=access_token))

이 흐름에서 request.COOKIES가 오염되면 인증이 깨진다. 쿠키가 "오염"되는 메커니즘을 파헤쳐 보자.


문제 1: Domain 속성의 스코프 규칙과 교차 전송

RFC 6265 Section 5.1.3 — Domain Matching

쿠키의 Domain 속성은 브라우저가 cookie jar에서 쿠키를 선택하는 기준이 된다.

RFC 6265에서 정의하는 domain-match 알고리즘:

A string domain-matches a given domain string if at least one of the following conditions holds:
1. The domain string and the string are identical.
2. The domain string is a suffix of the string, and the last character of the string that is not included in the domain string is a .

즉, Domain=.example.com으로 설정된 쿠키는 example.com 자체와 모든 서브도메인(*.example.com)에 domain-match된다.

Host-Only Flag

Set-CookieDomain 속성을 생략하면, 브라우저는 해당 쿠키에 host-only-flag=true를 설정한다 (RFC 6265 Section 5.3 Step 6). host-only 쿠키는 정확히 해당 호스트에만 매칭되며, 서브도메인에는 전송되지 않는다.

Set-Cookie: access=token                          → host-only: api-dev.example.com에만 전송
Set-Cookie: access=token; Domain=.example.com     → domain cookie: *.example.com 전체에 전송

RFC 6265 Section 5.3에 따르면, cookie jar에서 쿠키의 고유 식별자는 (name, domain, path) 조합이다. 이름이 동일해도 domain이 다르면 별개의 쿠키로 저장된다:

Cookie Jar:
  (access, .example.com,           /)  →  tokenA_dev
  (access, api-local.example.com,  /)  →  tokenB_local
  (refresh, .example.com,          /)  →  refreshA_dev
  (refresh, api-local.example.com, /)  →  refreshB_local

api-local.example.com에 요청 시, 위 4개 모두 domain-match되어 단일 Cookie 헤더에 합쳐져 전송된다:

Cookie: access=tokenB_local; refresh=refreshB_local; access=tokenA_dev; refresh=refreshA_dev

RFC 6265 Section 5.4: 브라우저는 path가 더 긴 쿠키를 먼저 배치하고, path 길이가 같으면 creation-time이 이른 쿠키를 먼저 배치한다. 단, 동일 path의 host-only vs domain cookie 간 순서는 명세에서 보장하지 않으며, 브라우저 구현에 따라 다르다.

Django는 Cookie 헤더를 파싱하여 request.COOKIES dict로 제공한다. 내부적으로 Python http.cookies.SimpleCookie.load()를 거친 뒤 dict로 변환하는데,

동일 키가 여러 번 등장하면 마지막 값이 남는다:

# django/http/request.py
@cached_property
def COOKIES(self):
    raw_cookie = self.META.get("HTTP_COOKIE", "")
    return parse_cookie(raw_cookie)  # → dict
# Cookie 헤더: access=tokenB_local; access=tokenA_dev
request.COOKIES = {"access": "tokenA_dev"}  # 마지막 값만 남음

결과적으로 서버는 dev DB에서 발급된 토큰을 받고, 이를 local DB에서 검증하려 한다:

  • RefreshToken(refresh_token): dev의 SECRET_KEY로 서명된 토큰을 local의 SECRET_KEY로 검증 → 서명 불일치 → TokenError
  • Session.objects.get(web_token=access_token): dev에서 발급된 access 토큰이 local DB에 없음 → DoesNotExist
  • 최악의 경우, access는 local 토큰, refresh는 dev 토큰이 선택되어 user_id 불일치 → MismatchToken
example.com
├── app-dev.example.com       ← 프론트엔드 (Dev)
├── api-dev.example.com       ← 백엔드 (Dev)
├── app.example.com           ← 프론트엔드 (Prod)
├── api.example.com           ← 백엔드 (Prod)

COOKIE_DOMAIN을 와일드카드(.example.com)에서 각 환경의 백엔드 도메인으로 변경:

환경변경 전변경 후
Dev.example.comapi-dev.example.com
Prod.example.comapi.example.com
Local미설정 (host-only)api-local.example.com

백엔드 엔드포인트가 프론트 서버와 동일하고 /api/ 경로로 통신할 경우에는 app-dev.example.com으로 설정하면 된다.

RFC 6265 Section 5.3 Step 5 — Domain 설정 제약: 서버는 자기 자신이거나 자신의 상위 도메인에만 Domain 속성을 설정할 수 있다. api-dev.example.com이 형제 도메인인 app-dev.example.com에 쿠키를 설정하면 브라우저가 거부한다. 따라서 COOKIE_DOMAIN은 반드시 백엔드 도메인이어야 한다.

기존 .example.com 스코프 쿠키를 정리하기 위해 OLD_COOKIE_DOMAIN을 변경 전 값으로 설정한다. 로그인/로그아웃 시 delete_cookies_from_response가 이 도메인의 쿠키를 삭제하도록 의도되어 있다.

그런데 삭제가 되지 않았다.


문제 2: Django SimpleCookie의 단일 키 제약

쿠키 삭제의 HTTP 레벨 메커니즘

HTTP에는 쿠키 삭제 명령이 없다. RFC 6265 Section 5.3 Step 11에 따라, 브라우저가 Set-Cookie를 받으면 cookie jar에서 (name, domain, path)가 일치하는 기존 쿠키를 교체한다. 따라서 삭제하려면 같은 식별자로 Max-Age=0Set-Cookie를 보내야 한다:

Set-Cookie: access=; Domain=.example.com; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT

이 응답을 받은 브라우저는 (access, .example.com, /)를 cookie jar에서 만료 처리하여 제거한다.

Django의 응답 헤더 생성 구조

Django HttpResponseBase는 두 개의 독립적인 저장소에서 응답 헤더를 생성한다:

class HttpResponseBase:
    def __init__(self):
        self.headers = ResponseHeaders(...)   # 일반 헤더 (Content-Type 등)
        self.cookies = SimpleCookie()          # Set-Cookie 전용

WSGI 핸들러가 응답을 직렬화할 때:

# django/core/handlers/wsgi.py
response_headers = [
    *response.items(),                                      # 일반 헤더
    *(("Set-Cookie", c.output(header=""))                   # 쿠키
      for c in response.cookies.values()),
]

response.items()self.headers에서 일반 헤더를 반환하고, response.cookies.values()SimpleCookie에서 쿠키를 반환한다.

SimpleCookie의 덮어쓰기 문제

set_cookie()delete_cookie() 모두 self.cookies[key]에 값을 대입한다:

# django/http/response.py
def set_cookie(self, key, value="", domain=None, ...):
    self.cookies[key] = value               # dict.__setitem__
    if domain is not None:
        self.cookies[key]["domain"] = domain
    ...

def delete_cookie(self, key, path="/", domain=None, ...):
    self.set_cookie(key, max_age=0, ...)    # 결국 self.cookies[key] 대입

dict이므로 같은 key에 대한 연속 대입은 마지막 값만 남는다:

# OLD_COOKIE_DOMAIN=.example.com, COOKIE_DOMAIN=api-dev.example.com

# delete_cookies_from_response 내부:
self.delete_cookie(key='access', domain='.example.com')
# self.cookies['access'] = Morsel(value='', domain='.example.com', max_age=0)

self.delete_cookie(key='access', domain='api-dev.example.com')
# self.cookies['access'] = Morsel(value='', domain='api-dev.example.com', max_age=0)
#                          ↑ 이전 .example.com 삭제 명령이 유실됨

# set_cookies_to_response 내부:
self.set_cookie(key='access', domain='api-dev.example.com', value='new_token')
# self.cookies['access'] = Morsel(value='new_token', domain='api-dev.example.com')
#                          ↑ 삭제 명령 자체가 유실됨

self.cookies를 직렬화하면 Set-Cookie 헤더는 쿠키 이름당 하나만 생성되므로, .example.com 도메인 쿠키의 삭제 명령은 응답에 포함되지 않는다.

이 버그는 COOKIE_DOMAIN 변경 전에도 존재했다. 다만 OLD_COOKIE_DOMAINCOOKIE_DOMAIN이 같은 스코프(.example.com)였기 때문에 "마지막 값만 남아도" 결과적으로 같은 도메인이라 문제가 드러나지 않았을 뿐이다. COOKIE_DOMAIN을 변경하는 순간 두 도메인이 달라지면서 표면화된 잠재 버그다.

해결 시도 1: Django HttpResponseBase_headers dict 활용

첫 번째 접근은 Django의 일반 헤더 저장소를 활용하는 것이었다. Django HttpResponseBase는 구버전에서 self._headers라는 dict에 일반 헤더를 저장했다:

# 구버전 Django의 HttpResponseBase
class HttpResponseBase:
    def __init__(self):
        self._headers = {}  # {소문자_헤더명: (원래_헤더명, 값)}

self._headers의 dict key는 내부 조회용이고, 실제 HTTP 헤더 이름은 튜플의 첫 번째 값이다. 서로 다른 dict key에 동일한 헤더 이름(Set-Cookie)을 넣으면, 각각 독립적인 헤더로 출력된다:

self._headers["set-cookie-delete-access-old"] = ("Set-Cookie", "access=; Domain=.example.com; Max-Age=0; ...")
self._headers["set-cookie-delete-access-new"] = ("Set-Cookie", "access=; Domain=api-dev.example.com; Max-Age=0; ...")
# → dict key가 다르므로 서로 덮어쓰지 않음

그러나 이 접근은 실패했다. CustomResponse는 Django의 HttpResponse가 아니라 DRF의 Response를 상속하며, DRF ResponseSimpleTemplateResponseHttpResponseBase를 거쳐 초기화된다. 이 과정에서 _headers가 아직 생성되지 않은 시점에 접근하면 AttributeError가 발생한다:

AttributeError: 'CustomResponse' object has no attribute '_headers'

해결 시도 2: ResponseHeaders._store dict 직접 조작

현재 Django 버전(5.x+)에서는 self._headers 대신 self.headers라는 ResponseHeaders 객체를 사용한다. ResponseHeadersCaseInsensitiveMapping을 상속하며, 내부 저장소는 self._store dict이다:

class ResponseHeaders(CaseInsensitiveMapping):
    def __init__(self, data):
        self._store = {}  # {소문자_key: (원래_key, 값)}

    def __setitem__(self, key, value):
        self._store[key.lower()] = (key, value)

_headers 대신 self.headers._store에 직접 접근하는 방식:

self.headers._store["set-cookie-delete-access-old"] = ("Set-Cookie", "access=; Domain=.example.com; Max-Age=0; ...")

이 방식은 동작하지만, _store는 Django 내부 private API다. Django 버전이 올라가면서 _store의 이름이나 구조가 바뀌면 코드가 깨진다. 프레임워크 내부 구현에 의존하는 것은 유지보수 리스크가 크다.

최종 해결: items() 메서드 오버라이드

WSGI 핸들러가 응답 헤더를 조립하는 코드를 다시 보자:

response_headers = [
    *response.items(),            # ← 이 메서드가 반환하는 것을 그대로 사용
    *(("Set-Cookie", c.output())
      for c in response.cookies.values()),
]

response.items()HttpResponseBase에 정의된 공개 메서드다. 이를 CustomResponse에서 오버라이드하면, Django 내부 API에 의존하지 않고도 추가 헤더를 끼워넣을 수 있다:

class CustomResponse(Response):
    def __init__(self, data=None, status=200, **kwargs):
        response_data = {"status": "success", "data": data}
        super().__init__(response_data, status=status, **kwargs)
        self._extra_cookie_headers = []    # 삭제용 Set-Cookie 문자열 보관

    def items(self):
        yield from super().items()         # 일반 헤더 (Content-Type 등)
        for cookie_header in self._extra_cookie_headers:
            yield ("Set-Cookie", cookie_header)  # 삭제용 Set-Cookie 추가

    def _add_raw_delete_cookie(self, key, domain=None):
        parts = [
            f"{key}=", "Max-Age=0", "Path=/",
            "Expires=Thu, 01 Jan 1970 00:00:00 GMT",
            "Secure", "SameSite=None",
        ]
        if domain:
            parts.append(f"Domain={domain}")
        self._extra_cookie_headers.append("; ".join(parts))

    def delete_cookies_from_response(self, is_admin=False):
        key_suffix = "admin_" if is_admin else ""
        cookie_keys = [f"{key_suffix}access", f"{key_suffix}refresh"]
        domains = [
            os.environ.get("OLD_COOKIE_DOMAIN") or None,
            os.environ.get("COOKIE_DOMAIN") or None,
        ]
        for key in cookie_keys:
            for domain in domains:
                if domain:
                    self._add_raw_delete_cookie(key=key, domain=domain)
            self._add_raw_delete_cookie(key=key)  # host-only 쿠키

이 방식의 장점:

  • _extra_cookie_headers는 리스트 → 같은 이름이라도 무한히 추가 가능 (dict 덮어쓰기 없음)
  • items() 오버라이드 → Django/DRF의 public API만 사용하므로 버전 업그레이드에 안전
  • self.cookies(SimpleCookie)와 완전히 독립적set_cookie()가 삭제 명령을 덮어쓸 수 없음

수정 후 응답 헤더 구조

# items()에서 생성 — _extra_cookie_headers 리스트 (삭제 명령)
Set-Cookie: access=; Domain=.example.com; Max-Age=0; Path=/; ...              ← OLD 도메인 삭제
Set-Cookie: refresh=; Domain=.example.com; Max-Age=0; Path=/; ...
Set-Cookie: access=; Domain=api-dev.example.com; Max-Age=0; Path=/; ...       ← 현재 도메인 삭제
Set-Cookie: refresh=; Domain=api-dev.example.com; Max-Age=0; Path=/; ...
Set-Cookie: access=; Max-Age=0; Path=/; ...                                    ← host-only 삭제
Set-Cookie: refresh=; Max-Age=0; Path=/; ...

# self.cookies에서 생성 — SimpleCookie (새 토큰)
Set-Cookie: access=new_token; Domain=api-dev.example.com; Secure; HttpOnly; SameSite=None; ...
Set-Cookie: refresh=new_token; Domain=api-dev.example.com; Secure; HttpOnly; SameSite=None; ...

브라우저는 RFC 6265 Section 5.3에 따라 각 Set-Cookie를 개별 처리한다. (access, .example.com, /)에 매칭되는 삭제 명령은 해당 쿠키를 만료시키고, (access, api-dev.example.com, /)에 매칭되는 설정 명령은 새 토큰을 저장한다.

최종 cookie jar 상태:

(access,  api-dev.example.com, /)  →  new_token    ← 유일한 쿠키
(refresh, api-dev.example.com, /)  →  new_token

요약

문제계층원인해결
환경 간 쿠키 교차 전송HTTP/브라우저Domain=.example.com이 모든 서브도메인에 매칭 (RFC 6265 §5.1.3)COOKIE_DOMAIN을 환경별 백엔드 도메인으로 격리
기존 도메인 쿠키 삭제 실패Django 프레임워크SimpleCookie(dict)가 쿠키 이름당 하나의 값만 보관 → 이전 도메인 삭제 명령 유실items() 오버라이드로 별도 리스트에서 삭제용 Set-Cookie 헤더 생성

핵심 포인트

  • 쿠키의 고유 식별자는 (Name, Domain, Path) 조합이다. 이름이 같아도 Domain이 다르면 별개의 쿠키로 저장되며, 동시에 전송될 수 있다. request.COOKIES가 dict인 이상, 서버 측에서 동일 이름 쿠키의 중복을 구분할 방법은 없다. 중복 자체를 방지해야 한다.
  • COOKIE_DOMAIN은 가능한 좁게 설정한다. 와일드카드(.example.com)는 편리하지만 모든 서브도메인에 전파된다. 특히 환경(local/dev/prod)이 같은 루트 도메인 아래에 있다면 반드시 환경별로 격리해야 한다.
  • 프레임워크의 쿠키 API가 HTTP 스펙의 모든 케이스를 커버하지는 않는다. Django의 delete_cookie()는 한 응답에서 동일 이름·다른 Domain의 쿠키를 삭제할 수 없다. 이는 SimpleCookie가 dict을 상속한 구조적 한계다. 우회 시에도 _headers_store 같은 private API 대신, items() 같은 공개 인터페이스를 오버라이드하는 것이 유지보수에 안전하다.

0개의 댓글