JWT Refresh Token 탈취 감지 — 서버 저장과 Reuse Detection
TL;DR
- 증상: 자동 토큰 갱신 시스템이 멀쩡한데도 전체 인증이 무효화됐습니다
- 원인: 두 갱신 주체가 같은 Refresh Token을 동시에 사용 → Reuse Detection 발동
- 해결: RT를 DB/Redis에 저장하고
isUsed플래그로 재사용 감지 + 갱신 주체 단일화 - 효과: RT 탈취 즉시 감지, 전체 세션 무효화 가능
- 한계: DB 조회 비용 발생, 분산 환경에서 Race Condition 별도 처리 필요
왜 AT와 RT를 따로 두는가
JWT 인증에서 Access Token(AT)과 Refresh Token(RT)을 왜 분리할까요.
AT만 쓰면 간단한데, 문제는 만료 시간입니다.
- AT 수명을 길게 → 탈취당하면 그 시간 내내 취약
- AT 수명을 짧게 → 사용자가 자꾸 로그아웃됨
그래서 나온 구조가 분리입니다.
- AT: 짧은 수명(15분~1시간). API 호출에 직접 사용. 탈취당해도 곧 만료됨
- RT: 긴 수명(7일~30일). AT가 만료되면 새 AT 발급용으로만 사용
AT가 만료되면 RT로 조용히 재발급받고, 사용자는 로그아웃 없이 계속 사용합니다. 이게 “토큰 갱신”의 기본 원리입니다.
RT를 서버에 저장해야 하는 이유
여기서 많은 구현이 놓치는 부분이 있습니다.
“RT도 JWT니까 서명만 검증하면 됩니다”
이 방식으로는 RT 탈취를 감지할 수 없습니다.
공격자가 RT를 탈취했다고 가정합니다. AT 만료 시마다 탈취한 RT로 새 AT를 발급받을 수 있습니다. 서버는 서명이 유효한지만 확인하고, 그 RT가 이미 다른 곳에서 사용됐는지 모릅니다.
RT가 만료될 때까지 공격자는 정상 동작합니다.
RT Reuse Detection은 이 문제를 해결합니다. 핵심은 RT의 사용 여부를 서버가 추적하는 것입니다.
@Transactional
public TokenPair refresh(String oldRt) {
RefreshToken stored = rtRepository.findByToken(oldRt)
.orElseThrow(() -> new InvalidTokenException("존재하지 않는 RT"));
// 이미 사용한 RT가 또 들어왔다 = 탈취 의심
if (stored.isUsed()) {
rtRepository.revokeAllByUserId(stored.getUserId());
throw new SecurityException("RT 재사용 감지 — 모든 세션 무효화");
}
stored.markAsUsed();
String newAt = jwtProvider.createAccessToken(stored.getUserId());
String newRt = jwtProvider.createRefreshToken(stored.getUserId());
rtRepository.save(new RefreshToken(newRt, stored.getUserId()));
return new TokenPair(newAt, newRt);
}이미 사용한 RT가 다시 들어오면 → 탈취 의심 → 해당 사용자의 모든 RT 즉시 무효화.
이 로직은 RT가 서버에 저장돼 있어야만 가능합니다. 서명 검증만으로는 “이 RT를 이미 썼는지”를 알 방법이 없습니다.
RT Rotation 설계
Reuse Detection과 함께 쓰는 패턴이 RT Rotation입니다. 갱신할 때마다 RT도 새로 발급하고, 이전 RT는 무효화합니다.
[초기 발급]
AT(1h) + RT(7d) 발급 → DB에 RT 저장
[AT 만료 시]
RT 제출 → DB 조회 → isUsed 확인 → markAsUsed → 새 AT + 새 RT 발급
↑
서버 저장 없으면 이 단계 불가sequenceDiagram
participant C as 클라이언트
participant S as 서버
participant DB as DB/Redis
C->>S: POST /auth/refresh (RT-A)
S->>DB: findByToken(RT-A)
DB-->>S: {isUsed: false}
S->>DB: markAsUsed(RT-A)
S->>DB: save(RT-B)
S-->>C: AT(새것) + RT-B
Note over C,S: 탈취 시나리오
C->>S: POST /auth/refresh (RT-A 재사용)
S->>DB: findByToken(RT-A)
DB-->>S: {isUsed: true}
S->>DB: revokeAll(userId)
S-->>C: 403 — 모든 세션 무효화현실 문제: 탭 두 개를 동시에 열면
Reuse Detection을 적용했을 때 흔하게 겪는 문제입니다.
브라우저 탭 A, B가 동시에 AT 만료를 감지하고 각각 RT로 갱신을 시도합니다.
탭 A → RT-X 사용 → 새 AT + RT-Y 발급
탭 B → (약간 늦게) 이미 쓴 RT-X 사용 시도 → Reuse Detection → 전체 로그아웃사용자 입장에서는 아무것도 안 했는데 강제 로그아웃입니다.
Grace Period로 해결합니다. 짧은 시간 내에 같은 RT로 들어오는 요청은 동일 응답을 반환합니다.
if (stored.isUsed()) {
// 5초 이내 재요청이면 동일 응답 반환 (탭 동시성 허용)
if (isWithinGracePeriod(stored.getUsedAt(), 5)) {
return stored.getLastIssuedPair();
}
// 5초 초과면 탈취로 판단
rtRepository.revokeAllByUserId(stored.getUserId());
throw new SecurityException("RT 재사용 감지");
}탭 동시성은 허용하되, 진짜 탈취(시간 간격이 큰 재사용)는 차단합니다.
직접 겪었습니다
이 개념이 이론이 아니라는 걸 직접 확인하게 됐습니다.
Claude API 기반 개인 AI 어시스턴트 자비스를 운영하다가 갑자기 전체 인증이 무효화됐습니다.
Error: Invalid authentication credentials
API Error: 401토큰이 만료됐나 싶었는데, 갱신해도 또 401이 났습니다.
원인은 갱신 주체가 두 개였습니다.
oauth-refresh.sh— LaunchAgent로 1시간마다 자동 갱신- Claude CLI 자체 — 토큰 만료 임박 시 자동 갱신
두 주체가 비슷한 시점에 동시에 갱신을 시도했고, Anthropic 서버는 이미 쓴 Refresh Token이 다시 들어왔다고 판단해 계정의 모든 토큰을 무효화했습니다.
[oauth-refresh.sh] → RT-A 사용 → 새 AT + RT-B 발급
[Claude CLI] → (약간 늦게) 이미 쓴 RT-A 사용 시도
Anthropic: "RT-A는 이미 사용됨. 탈취 의심 → 전체 무효화"Anthropic은 앞서 설명한 RT Reuse Detection을 정확히 구현하고 있었습니다.
해결은 간단했습니다. CLI가 “토큰이 곧 만료된다”고 느끼지 못하도록, 항상 충분히 미리 갱신해두는 것이었습니다.
RENEW_THRESHOLD_SECS=14400 # 만료 4시간 전에 갱신 (8시간 토큰의 절반)만료까지 항상 4시간 이상 여유가 있으니, CLI가 자체 갱신을 실행할 조건 자체가 성립하지 않습니다. 갱신 주체가 사실상 하나가 됩니다.
이 경험에서 한 가지가 분명해졌습니다. RT Reuse Detection은 RT를 서버에 저장하지 않으면 구현 자체가 불가능합니다. Anthropic이 탈취를 감지할 수 있는 건 발급한 RT의 사용 여부를 기록하기 때문입니다.
서버 저장 없는 RT의 문제
// 이렇게만 하면 탈취 감지 불가
public boolean validateRt(String rt) {
return jwtProvider.isValid(rt); // 서명 검증만
}이 구현은 RT 수명 내내 탈취된 토큰을 막을 방법이 없습니다. 공격자가 RT를 훔쳤다면 수명이 다할 때까지 사용 가능합니다.
// Reuse Detection 하려면 저장 필수
public RefreshToken findStoredRt(String rt) {
return rtRepository.findByToken(rt)
.orElseThrow(() -> new InvalidTokenException("존재하지 않는 RT"));
}Redis를 쓰면 TTL 설정으로 만료 관리를 자동화할 수 있습니다. RT 수명과 TTL을 맞춰두면 만료된 RT는 자동 삭제됩니다.
내 시스템 점검 체크리스트
- RT를 DB 또는 Redis에 저장하고 있는가?
- RT 사용 후
isUsed처리 및 신규 RT 발급(Rotation)이 되는가? - 이미 사용한 RT 재요청 시 전체 세션 무효화가 동작하는가?
- 탭 동시성(5초 이내 같은 RT 재요청) Grace Period 처리가 있는가?
- AT 수명이 적절한가? (15분~1시간 권장, 길수록 탈취 위험 증가)
마무리
RT는 “긴 수명 AT”가 아닙니다. 서버가 추적하고 관리해야 하는 상태 있는 토큰입니다.
서명만 검증하는 방식은 구현이 쉽지만, 탈취 감지라는 핵심 기능을 포기하는 선택입니다. RT를 서버에 저장하는 순간 Reuse Detection, Rotation, 전체 세션 무효화가 가능해집니다.
자비스 운영 중 직접 겪은 사고가 이 사실을 가장 명확하게 확인해줬습니다.
