ADR 7: HttpOnly Refresh Token 마이그레이션
Refresh Token을 클라이언트 JS 접근 가능한 쿠키에서 HttpOnly 쿠키로 전환하여 XSS 공격 표면을 줄이는 아키텍처 결정
Status
Accepted
Context
기존 인증 구조에서는 Access Token(mdLoginToken)과 Refresh Token(mdRefreshToken) 모두 js-cookie를 통해 클라이언트에서 직접 관리하고 있었어요.
sequenceDiagram
participant Browser
participant Client JS
participant Server
Browser->>Server: POST /auth/login
Server-->>Client JS: { token, refreshToken }
Client JS->>Client JS: Cookies.set(mdLoginToken, token)
Client JS->>Client JS: Cookies.set(mdRefreshToken, refreshToken)
Note over Client JS: XSS 발생 시 두 토큰 모두 탈취 가능이 구조에서 다음과 같은 문제가 있었어요:
- 보안: Refresh Token이
document.cookie로 접근 가능하여 XSS 공격 시 장기 유효 토큰이 탈취될 수 있어요 - 사용자 경험: Access Token 만료 시 쿠키를 삭제하고 페이지를 reload하는 방식이라, 사용자가 작업 중 세션이 끊기는 문제가 있었어요
- 코드 품질: 여러 파일에서
Cookies.get()+authorization헤더를 수동으로 삽입하는 패턴이 반복되고 있었어요 - 표준 미준수:
Authorization헤더에 RFC 6750의Bearerprefix를 사용하지 않고 있었어요
Options Considered
Option 1: Access Token도 HttpOnly 쿠키로 전환
모든 토큰을 서버 관리 HttpOnly 쿠키로 전환하는 방식이에요.
- 장점: 클라이언트에서 토큰을 전혀 다루지 않아 보안이 가장 강력해요
- 단점: WebSocket 인증(
connectionParams), fork된 Excalidraw의 socket.io 연결 등 쿠키가 아닌 경로로 토큰을 전달해야 하는 케이스가 있어 전면 전환이 어려워요. 또한 SSR/RSC에서 서버 컴포넌트가 직접 토큰을 읽어야 하는 요구사항과 충돌해요
Option 2: Refresh Token만 HttpOnly 쿠키로 전환 ✅ (채택)
Refresh Token만 HttpOnly로 전환하고, Access Token은 기존처럼 클라이언트에서 관리하는 하이브리드 방식이에요.
- 장점: 기존 WebSocket, SSR 등의 토큰 전달 방식을 유지하면서 장기 유효 토큰(Refresh Token)의 보안을 강화할 수 있어요. 점진적 마이그레이션이 가능해요
- 단점: Access Token은 여전히 XSS에 노출되지만, 짧은 만료 시간으로 위험을 최소화할 수 있어요
Option 3: 현상 유지 + CSP 강화
토큰 저장 방식은 변경하지 않고 Content Security Policy를 강화하는 방식이에요.
- 장점: 클라이언트 코드 변경이 최소화돼요
- 단점: CSP만으로는 모든 XSS 벡터를 차단할 수 없고, Refresh Token 탈취 위험이 근본적으로 해결되지 않아요
Decision
Option 2를 채택해요. Refresh Token을 서버 관리 HttpOnly 쿠키로 전환하고, 클라이언트에는 토큰 갱신 매니저를 도입해요.
핵심 설계 결정
1. 토큰 저장 전략
sequenceDiagram
participant Browser
participant Client JS
participant Server
Browser->>Server: POST /auth/login
Server-->>Browser: Set-Cookie: mdRefreshToken (HttpOnly, Secure, SameSite)
Server-->>Client JS: { token }
Client JS->>Client JS: Cookies.set(mdLoginToken, token)
Note over Browser: mdRefreshToken은 JS에서 접근 불가- Access Token: 기존과 동일하게
js-cookie로 클라이언트 관리 (WebSocket, SSR 호환) - Refresh Token: 서버가
Set-Cookie로 설정하는 HttpOnly 쿠키 (JS 접근 불가)
2. 토큰 갱신 — 2단계 방어 전략
단일 방어가 아닌 선제 갱신 + 서버 401 fallback의 2단계 구조를 채택했어요. 서버와 클라이언트의 시간 차이, 네트워크 지연 등 edge case에 대응하기 위해서에요.
flowchart TD
A[GraphQL/REST 요청] --> B{Access Token 만료 60초 전?}
B -->|Yes| C["1단계: authLink에서 선제 refresh"]
B -->|No| D[정상 요청 진행]
C -->|성공| D
C -->|실패| E["handleRefreshFailure → 홈으로 리다이렉트"]
D --> F{서버 응답}
F -->|200 OK| G[완료]
F -->|401 UNAUTHENTICATED| H["2단계: errorLink/인터셉터에서 refresh"]
H -->|성공| I[새 토큰으로 원래 요청 재시도]
H -->|실패| E- 1단계 (선제): Apollo
authLink에서jwtDecode로 만료 60초 전 감지 →refreshAccessToken()호출 - 2단계 (fallback): Apollo
errorLink또는 axios 응답 인터셉터에서UNAUTHENTICATED/401 감지 → refresh 후 원래 요청 재시도
3. Race Condition 방지 — 싱글톤 큐잉 패턴
동시에 여러 요청이 401을 받는 경우, /auth/refresh가 중복 호출되지 않도록 tokenRefreshManager에서 싱글톤 큐잉 패턴을 사용해요. Apollo와 REST 모두 동일한 매니저를 공유해요.
sequenceDiagram
participant Req1 as 요청 1 (401)
participant Req2 as 요청 2 (401)
participant Req3 as 요청 3 (401)
participant TRM as tokenRefreshManager
participant Server
Req1->>TRM: refreshAccessToken()
Note over TRM: isRefreshing = true
TRM->>Server: POST /auth/refresh
Req2->>TRM: refreshAccessToken()
Note over TRM: pendingRequests 큐에 대기
Req3->>TRM: refreshAccessToken()
Note over TRM: pendingRequests 큐에 대기
Server-->>TRM: { accessToken: newToken }
TRM-->>Req1: newToken
TRM-->>Req2: newToken (큐에서 resolve)
TRM-->>Req3: newToken (큐에서 resolve)
Note over TRM: isRefreshing = false4. 인증 코드 일원화
파편화된 REST 인증 코드를 createAuthAxios 팩토리와 getAuthHeaders 헬퍼로 통합했어요. 토큰 삽입, 401 재시도 로직이 한 곳에서 관리되므로 향후 인증 방식 변경 시 수정 범위가 최소화돼요.
graph LR
subgraph "Before"
A1[파일 A: Cookies.get + headers] --> S[Server]
A2[파일 B: Cookies.get + headers] --> S
A3[파일 C: Cookies.get + headers] --> S
end
subgraph "After"
B1[파일 A] --> CA[createAuthAxios]
B2[파일 B] --> CA
B3[파일 C] --> CA
CA -->|"Bearer token + 401 retry"| S2[Server]
end5. 로그아웃 시 서버 세션 정리
로그아웃 시 POST /auth/logout (credentials: include)을 호출하여 서버 측 HttpOnly 쿠키를 삭제해요. handleRefreshFailure()에서도 fire-and-forget으로 동일한 호출을 수행하여, refresh 실패로 인한 강제 로그아웃 시에도 서버 세션이 정리돼요.
6. AIDT 유저 예외
AIDT(디지털교과서) 유저는 별도 SSO 인증 체계를 사용하므로 토큰 갱신 매니저를 적용하지 않아요. 기존 AIDT 로그인/로그아웃 로직을 그대로 유지해요.
7. Bearer prefix 표준화
RFC 6750에 따라 모든 Authorization 헤더에 Bearer prefix를 일괄 적용했어요. GraphQL(Apollo), REST(axios/fetch), WebSocket(connectionParams), SSR(RSC) 모든 경로에 동일하게 적용돼요.
Consequences
장점
- Refresh Token 보안 강화: HttpOnly 쿠키로 전환하여 XSS 공격 시 장기 유효 토큰 탈취 불가
- 끊김 없는 세션 유지: 토큰 만료 시 페이지 reload 없이 백그라운드에서 갱신
- 유지보수성 향상: 인증 로직이
tokenRefreshManager,createAuthAxios로 집중되어 변경 영향 범위 최소화 - 표준 준수: RFC 6750 Bearer Token 형식 적용
주의사항
- 서버 구현 필수:
POST /auth/refresh,POST /auth/logout엔드포인트와Set-Cookie(HttpOnly, Secure, SameSite) 설정이 서버에 준비되어야 해요 - CORS 설정:
credentials: 'include'사용으로 서버의Access-Control-Allow-Credentials: true와 명시적Access-Control-Allow-Origin설정이 필요해요 (와일드카드*사용 불가) - Access Token 노출: Access Token은 여전히 클라이언트에서 접근 가능하지만, 짧은 만료 시간으로 위험을 제한해요
미확인 사항
- 핸드라이팅 WebSocket 서버에서 socket.io query parameter로 전달하는 토큰의 Bearer prefix 필요 여부 — 서버 확인 후 결정 필요