12.6 실전 고수 팁 — Refresh Token 탈취(RTR) 방어 아키텍처 및 다중 디바이스 동시 로그인 제어
JWT(무상태성 인증)의 가장 치명적인 약점은 "한 번 발급되어 누군가(해커)의 손에 들어간 순간, 서버 측에서 막을 힘(강제 무효화) 통제권이 전혀 없다는 것"입니다. 유효기간(Exp)이 끝날 때까지 해커는 유유히 로그인 서비스를 유린합니다.
이를 해결하기 위해 엑세스 토큰(Access Token)의 수명을 아주 짧게(30분) 줄이고, 수명이 긴 2주짜리 리프레시 토큰(Refresh Token) 을 발급하여 엑세스 토큰을 연장하는 방식을 썼지만, 이마저도 해커가 리프레시 토큰을 평생 탈취해버리면 영구 로그인이 되는 참사가 발생합니다.
🔄 1. RTR (Refresh Token Rotation) 방어 아키텍처
고수들의 아키텍처인 RTR 기법 은 "리프레시 토큰을 사용하여 엑세스 토큰을 갱신(재발급)해갈 때, 기존에 사용했던 리프레시 토큰 본인 자신마저도 즉시 파기하고(1회용), 완전히 새로운 리프레시 토큰을 계속해서 교체 발급 해주는 행위"를 말합니다.
🛡️ RTR 로직 핵심 시나리오
- 정상 유저 행동: 엑세스 토큰 죽음 ->
RT_A로 서버에 연장 요청 -> 서버는 확인 후 기존RT_A를 파기시키고(서버 DB/Redis에서 블랙리스트 처리), 새Access와RT_B를 발급해 넘김. - 해커의 행동: 해커가
RT_A를 훔침. - 충돌 메커니즘 🚨:
정상 유저가 먼저
RT_A로 토큰을 교체해 새 묶음(RT_B)을 가져갔다고 가정합시다. 이 순간RT_A는 서버 메모리에서 이미 폐기된 구형 토큰 목록 에 등록됩니다. 그 후 해커가 훔친 구형토큰RT_A를 서버에 들이밀면? 서버는 "어라? 이 리프레시 토큰(RT_A)은 아까 이미 누군가(원주인) 정상적으로 사용하고 교환해 간 과거 버전이잖아! 이거 복제당했네!" 라고 감지하게 됩니다. - 멸망 버튼(Kill Switch): 서버는 복제를 감지하는 즉시 그 유저의 모든 연결 고리를 무자비하게 끊어버립니다(새로 뽑은
RT_B계열 전체를 블랙리스트 날림). 정상 유저든 해커든 모두 재로그인 창(ID/PW)으로 강제 튕겨버려 방어에 성공합니다.
🏎️ 2. Redis를 이용한 RTR 및 무상태(Stateless) 강제 만료 로직 구현
이러한 로직을 물리적 MySQL(RDB) 테이블을 만들어서 검사하면 I/O 병목으로 로그인 갱신마다 버벅거립니다. 토큰의 수명 구조와 똑같은 TTL(Time To Live) 메커니즘을 자체 보유한 In-Memory 엔진 Redis 저장소 가 완벽한 해결책입니다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtProvider jwtProvider;
public TokenDto reissueToken(String oldRefreshToken) {
// 1. 레디스에서 해당 구형 토큰이 정상 보유 중인지(블랙리스트가 아닌지) 검사
String userId = jwtProvider.extractUserId(oldRefreshToken);
String redisToken = redisTemplate.opsForValue().get("RT:" + userId);
if (redisToken == null || !redisToken.equals(oldRefreshToken)) {
// [해커 감지] "내 DB에 없거나, 남이 탈취해서 쓴 이미 폐기된 구버전이다!"
// 연쇄적인 멸망 스위치: 해당 유저의 모든 토큰 패밀리 트리 즉각 압수 및 로그아웃 처리
redisTemplate.delete("RT:" + userId);
throw new MaliciousTokenAttackException("탈취된 조작 토큰 감지! 전체 강제 로그아웃!");
}
// 2. 정상 유저 확인 -> 그 즉시 이전 토큰 파기 및 완전 새로운 1회성 RT_B 묶음 발급
String newAccessToken = jwtProvider.createAccessToken(userId);
String newRefreshToken = jwtProvider.createRefreshToken(userId); // 아예 새것 (RTR)
// 3. 레디스 캐시에 새로운 RT_B 덮어쓰기 저장 (유효기간 TTL 자동 폭발 옵션 14일 세팅!)
redisTemplate.opsForValue().set("RT:" + userId, newRefreshToken, 14, TimeUnit.DAYS);
return new TokenDto(newAccessToken, newRefreshToken);
}
}
🚫 3. JWT 동시 접속 제어 컨트롤 (넷플릭스 밴)
"A 유저가 모바일에서 로그인했는데, 동시에 우즈베키스탄에서 누군가 또 접속하면 이전 모바일의 연결 로그인을 강제로 끊고 싶습니다." 이른바 동시 로그인 통제 세션 기법도 위와 같은 방식의 연장선인 Redis로 완벽하게 해결합니다.
유저 아이디 PK(user_102)를 Key로 잡고, 방금 로그인 할 때 새로 뽑은 현재의 최신 Access Token 자체의 해시값(Signature) 을 Value 로 물고 있게끔 Redis에 기록(Update)합니다.
이후 해당 유저가 API 통신을 해올 때마다, 유저가 들고 온 토큰의 서명값과 레디스(서버 메모리)에 등록된 최신 발급 서명값이 불일치(즉, 아까 누군가 다른 기기에서 재로그인하면서 레디스 값을 새로 덮어써버린 상태)한다면 가차 없이 "중복 기기 로그인으로 만료되었습니다" 라며 401 Unauthorized 필터 차단을 때려버리는 논리로 구현됩니다.