
dev 서버에서 로그인한 뒤 local로 돌아오면 401이 뜬다. 토큰은 유효하고, DB에도 세션이 있다. 그런데 서버에 있어서는 안 될 토큰이 있다.
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 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가 오염되면 인증이 깨진다. 쿠키가 "오염"되는 메커니즘을 파헤쳐 보자.
쿠키의 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된다.
Set-Cookie에 Domain 속성을 생략하면, 브라우저는 해당 쿠키에 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로 검증 → 서명 불일치 → TokenErrorSession.objects.get(web_token=access_token): dev에서 발급된 access 토큰이 local DB에 없음 → DoesNotExistuser_id 불일치 → MismatchTokenexample.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.com | api-dev.example.com |
| Prod | .example.com | api.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가 이 도메인의 쿠키를 삭제하도록 의도되어 있다.
그런데 삭제가 되지 않았다.
SimpleCookie의 단일 키 제약HTTP에는 쿠키 삭제 명령이 없다. RFC 6265 Section 5.3 Step 11에 따라, 브라우저가 Set-Cookie를 받으면 cookie jar에서 (name, domain, path)가 일치하는 기존 쿠키를 교체한다. 따라서 삭제하려면 같은 식별자로 Max-Age=0인 Set-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 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에서 쿠키를 반환한다.
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_DOMAIN과 COOKIE_DOMAIN이 같은 스코프(.example.com)였기 때문에 "마지막 값만 남아도" 결과적으로 같은 도메인이라 문제가 드러나지 않았을 뿐이다. COOKIE_DOMAIN을 변경하는 순간 두 도메인이 달라지면서 표면화된 잠재 버그다.
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 Response는 SimpleTemplateResponse → HttpResponseBase를 거쳐 초기화된다. 이 과정에서 _headers가 아직 생성되지 않은 시점에 접근하면 AttributeError가 발생한다:
AttributeError: 'CustomResponse' object has no attribute '_headers'
ResponseHeaders._store dict 직접 조작현재 Django 버전(5.x+)에서는 self._headers 대신 self.headers라는 ResponseHeaders 객체를 사용한다. ResponseHeaders는 CaseInsensitiveMapping을 상속하며, 내부 저장소는 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 헤더 생성 |
request.COOKIES가 dict인 이상, 서버 측에서 동일 이름 쿠키의 중복을 구분할 방법은 없다. 중복 자체를 방지해야 한다.COOKIE_DOMAIN은 가능한 좁게 설정한다. 와일드카드(.example.com)는 편리하지만 모든 서브도메인에 전파된다. 특히 환경(local/dev/prod)이 같은 루트 도메인 아래에 있다면 반드시 환경별로 격리해야 한다.delete_cookie()는 한 응답에서 동일 이름·다른 Domain의 쿠키를 삭제할 수 없다. 이는 SimpleCookie가 dict을 상속한 구조적 한계다. 우회 시에도 _headers나 _store 같은 private API 대신, items() 같은 공개 인터페이스를 오버라이드하는 것이 유지보수에 안전하다.