프론트엔드 개발 시 LocalStorage에 JWT를 저장하면 XSS 공격 한 번에 모든 권한이 탈취됩니다. 반대로 쿠키에만 의존하면 CSRF 공격의 표적이 되기 쉽습니다.
이 글은 HttpOnly 쿠키와 Refresh Token Rotation(RTR)을 결합해 세션 하이재킹을 원천 봉쇄하는 보안 아키텍처를 제시합니다.
TL;DR — Access Token은 메모리(JS 변수)에, Refresh Token은 HttpOnly 쿠키에 저장하고 요청마다 Refresh Token을 교체(RTR)하여 보안을 극대화합니다.
1. Refresh Token Rotation(RTR)이란
💡 비유로 이해하기: 은행 금고 열쇠(Access Token)가 만료될 때마다 새로운 보관함 번호표(Refresh Token)를 받고, 기존 번호표는 즉시 폐기하는 것과 같습니다. 누군가 내 번호표를 훔쳐가도 내가 먼저 새 번호표를 받는 순간 도둑의 번호표는 종잇조각이 됩니다.
Refresh Token Rotation(RTR)은 클라이언트가 Access Token을 갱신할 때마다 새로운 Refresh Token을 함께 발급하는 메커니즘입니다. RFC 6749 표준을 기반으로 하며, 한 번 사용한 Refresh Token은 즉시 무효화됩니다.
기존의 고정형 Refresh Token 방식은 토큰이 탈취되었을 때 공격자가 유효기간 내내 무단으로 Access Token을 생성할 수 있는 취약점이 있었습니다. RTR은 토큰 재사용 감지(Reuse Detection) 기능을 통해 공격자의 접근을 즉각 차단합니다.
2. RTR이 반드시 필요한 실무 상황
공용 PC나 보안이 취약한 네트워크 환경에서 사용자가 로그인을 유지할 때 필수입니다. 해커가 XSS 스크립트를 통해 쿠키를 가로채더라도, RTR이 적용되어 있다면 해커가 토큰을 사용하는 즉시 정상 사용자의 토큰까지 무효화되어 이상 징후를 감지할 수 있습니다.
또한, 모바일 앱과 웹이 공존하는 환경에서 무국적(Stateless) 인증의 장점을 유지하면서도 세션 탈취 피해를 최소화해야 하는 경우에 사용합니다. 특히 금융 서비스나 개인정보 민감도가 높은 엔터프라이즈 환경에서는 RTR 도입이 표준으로 자리 잡고 있습니다.
3. 단계별 RTR 구현 가이드
Node.js와 Express 환경을 기준으로 클라이언트와 서버 간의 토큰 교체 흐름을 구현합니다.
Step 1. HttpOnly 쿠키 설정 및 발급
Refresh Token은 자바스크립트 접근이 불가능하도록 설정해야 합니다.
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true, // HTTPS 필수
sameSite: 'Strict',
path: '/api/auth/refresh', // 특정 경로에서만 전송
maxAge: 7 * 24 * 60 * 60 * 1000 // 7일
});
Step 2. 토큰 교체 및 재사용 감지 로직
서버는 데이터베이스에 저장된 이전 토큰과 클라이언트가 보낸 토큰을 대조합니다.
app.post('/api/auth/refresh', async (req, res) => {
const takenToken = req.cookies.refreshToken;
const user = await User.findByToken(takenToken);
if (!user) {
// 재사용 감지: 이미 사용된 토큰으로 접근 시 해당 유저의 모든 토큰 무효화
await TokenStorage.revokeAll(takenToken.userId);
return res.status(403).json({ error: 'Reused token detected' });
}
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
// DB 업데이트: 기존 토큰 삭제 후 새 토큰 저장
await TokenStorage.rotate(takenToken, newRefreshToken);
res.cookie('refreshToken', newRefreshToken, { httpOnly: true });
res.json({ accessToken: newAccessToken });
});
Step 3. 프론트엔드 Access Token 관리
Access Token은 메모리에 저장하고, Axios 인터셉터를 사용해 만료 시 자동으로 갱신 요청을 보냅니다.
axios.interceptors.response.use(
response => response,
async error => {
if (error.response.status === 401) {
const { accessToken } = await axios.post('/api/auth/refresh');
// 새로운 Access Token을 메모리 변수에 저장 후 기존 요청 재시도
authStore.setToken(accessToken);
error.config.headers['Authorization'] = `Bearer ${accessToken}`;
return axios(error.config);
}
return Promise.reject(error);
}
);
4. LocalStorage vs HttpOnly 쿠키 vs RTR
각 방식의 보안 수준과 트레이드오프를 비교합니다.
| 기준 | LocalStorage | HttpOnly 쿠키 | HttpOnly + RTR |
|---|---|---|---|
| XSS 방어 | 매우 취약 | 강함 (JS 접근 불가) | 매우 강함 |
| CSRF 방어 | 안전 (직접 첨부 필요) | 취약 (자동 전송) | 안전 (Rotation으로 상쇄) |
| 토큰 탈취 시 대응 | 불가능 | 만료 전까지 무방비 | 즉시 무효화 가능 |
| 구현 복잡도 | 낮음 | 중간 | 높음 |
보안이 최우선이라면 HttpOnly 쿠키 + RTR 조합을 선택하는 것이 현재 가장 권장되는 패턴입니다.
5. 주의사항
⚠️ 가장 자주 하는 실수: 이미 사용된 Refresh Token이 들어왔을 때 단순히 에러만 반환하면 안 됩니다. 이는 누군가 토큰을 훔쳐 사용했다는 강력한 신호이므로, 해당 사용자와 연결된 모든 세션을 즉시 파기해야 합니다.
또한, 동시성 이슈가 발생할 수 있습니다. 사용자가 여러 탭을 띄워놓았을 때, 여러 개의 갱신 요청이 동시에 날아가면 첫 번째 요청 이후의 토큰들은 재사용으로 간주될 수 있습니다. 이를 방지하기 위해 서버 측에서 약 500ms~1000ms 정도의 유예 기간(Grace Period)을 두는 것이 좋습니다.
에러 메시지별 해결법
Error: Token Reuse Detected
원인: 클라이언트가 이미 교체된 이전 Refresh Token으로 접근함 (공격 시도 또는 Race Condition)
해결: 서버 DB의 해당 유저 리프레시 토큰 화이트리스트 전체 삭제 및 강제 로그아웃
6. 실전 보안 강화 팁
Access Token의 유효 기간은 15분 이내로 짧게 설정하고, Refresh Token은 사용자의 IP 주소나 User-Agent 정보와 결합해 검증하면 더욱 안전합니다.
서버 측에서는 Redis와 같은 In-memory DB를 활용해 Refresh Token의 화이트리스트를 관리하세요. Redis의 TTL 기능을 사용하면 만료된 토큰을 자동으로 정리할 수 있어 관리가 용히합니다.
📌 핵심 요약
- XSS 방어를 위해 Refresh Token은 반드시 HttpOnly 쿠키에 저장합니다.
- RTR을 통해 토큰이 한 번만 사용되도록 강제하고 재사용을 감지합니다.
- 재사용 감지 시 해당 사용자의 모든 세션을 즉시 무효화하여 피해를 최소화합니다.
Frequently Asked Questions
Q. 여러 탭을 사용할 때 로그아웃되는 문제는 어떻게 해결하나요?
A. 서버에서 토큰 교체 시 1~2초의 짧은 유예 기간을 두어 동시 요청을 허용합니다.
Q. RTR을 쓰면 무조건 CSRF로부터 안전한가요?
A. 아닙니다. SameSite 설정을 Strict로 하고 추가적인 CSRF 토큰 검증이 병행되어야 합니다.
Q. Refresh Token도 결국 탈취당하면 끝 아닌가요?
A. RTR은 탈취된 토큰의 수명을 단 1회성으로 제한하여 피해 확산을 막는 목적입니다.
Post a Comment