이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Access token과 refresh token은 왜 수명과 저장소를 다르게 설계하는가?
- refresh token rotation과 reuse detection은 어떤 공격을 탐지하는가?
- 재발급 API에서 어떤 로그와 폐기 정책이 필요한가?
개요
Access token은 API 호출에 쓰는 짧은 수명의 bearer credential이다.
Refresh token은 새 access token을 받기 위한 더 긴 수명의 credential이다. 그래서 access token보다 더 강한 저장, 회전, 폐기 정책이 필요하다.
둘을 나누는 이유는 피해 범위를 줄이기 위해서다. access token은 짧게 끝내고, refresh token은 재발급 경계에서 더 많은 검증과 탐지를 수행한다.
공격 시나리오
공격자가 사용자의 refresh token을 탈취했다.
정상 사용자와 공격자가 거의 동시에 refresh endpoint를 호출한다.
- 정상 사용자는 refresh token A로 access token과 refresh token B를 받는다.
- 공격자도 탈취한 refresh token A를 다시 보낸다.
- 서버가 단순히 만료 시간만 보면 공격자에게도 새 token을 발급한다.
- 서버가 reuse detection을 하면 token A의 재사용을 탈취 신호로 보고 token family를 폐기한다.
rotation은 단순히 token 값을 바꾸는 기능이 아니라 탈취 탐지 장치다.
취약한 요청/응답 예시
POST /api/auth/refresh HTTP/1.1
Host: api.example.com
Cookie: refresh_token=rt-old-stolen
Content-Type: application/json
{}HTTP/1.1 200 OK
Set-Cookie: refresh_token=rt-new-again; Path=/; HttpOnly; Secure
Content-Type: application/json
{"accessToken":"at-new","expiresIn":3600}기존 refresh token이 이미 사용되었는지 확인하지 않으면 탈취자가 세션을 계속 연장할 수 있다.
주제별 핵심 판단
login success
-> issue access token, refresh token
api request
-> validate access token
access token expired
-> refresh endpoint
-> validate refresh token record
-> rotate refresh token
-> issue next access token
logout or risk detected
-> revoke refresh token family재발급 API는 인증 전 endpoint처럼 보이지만 실제로는 인증 상태를 연장하는 고위험 경계다.
개선된 요청/응답 예시
Refresh token 저장소에는 원문 token을 저장하지 않는다.
- 개선된 refresh 흐름은 token family와 rotation 상태를 기록하고 재사용을 사고 이벤트로 처리한다.
- access token은 짧게, refresh token은 저장소 hash와 기기 세션 기준으로 관리한다.
방어 설계
refresh_token_record
id
account_id
token_hash
family_id
parent_id
status: ACTIVE, USED, REVOKED, REUSED
issued_at
expires_at
used_at
device_id_hash회전 규칙은 명확해야 한다.
- ACTIVE token이 들어오면 USED로 바꾸고 새 ACTIVE token을 발급한다.
- USED token이 다시 들어오면 reuse로 보고 family 전체를 폐기한다.
- REVOKED token이 들어오면 재발급하지 않고 보안 이벤트를 남긴다.
- 동시 요청 race를 처리하기 위해 DB lock, optimistic locking, 짧은 grace 정책 중 하나를 선택한다.
- 비밀번호 변경, MFA 해제, 계정 잠금, 탈취 의심 시 family 단위 폐기를 지원한다.
Spring/HTTP 예시
@PostMapping("/api/auth/refresh")
public ResponseEntity<TokenRefreshResponse> refresh(
@CookieValue("refresh_token") String refreshToken,
HttpServletResponse response,
HttpServletRequest request) {
TokenRotationResult result = refreshTokenService.rotate(
refreshToken,
ClientSignal.from(request));
Cookie cookie = refreshCookieFactory.create(result.refreshToken());
response.addCookie(cookie);
return ResponseEntity.ok(new TokenRefreshResponse(result.accessToken(), result.expiresIn()));
}@Transactional
public TokenRotationResult rotate(String rawRefreshToken, ClientSignal signal) {
String tokenHash = tokenHasher.hash(rawRefreshToken);
RefreshTokenRecord record = refreshTokenRepository.findForUpdate(tokenHash)
.orElseThrow(() -> rejected("unknown_refresh_token", signal));
if (record.used()) {
refreshTokenRepository.revokeFamily(record.familyId(), "reuse_detected");
audit.refreshReuseDetected(record.accountId(), record.familyId(), signal);
throw new AccessDeniedException("refresh token rejected");
}
if (!record.activeOrNotExpired()) {
audit.refreshRejected(record.accountId(), record.familyId(), "expired_or_revoked");
throw new AccessDeniedException("refresh token rejected");
}
record.markUsed();
RefreshTokenRecord next = refreshTokenRepository.issueNext(record, signal.deviceIdHash());
AccessToken accessToken = accessTokenIssuer.issue(record.accountId());
audit.refreshRotated(record.accountId(), record.familyId(), next.id());
return TokenRotationResult.of(accessToken.value(), next.rawToken(), accessToken.expiresIn());
}운영 로그와 감사 지점
level=INFO event=refresh_token_rotated result=success account_id=1004 family_id=fam_8a old_token_id=rt_17 new_token_id=rt_18 device_id_hash=9ab1 trace_id=8c71ee01level=WARN event=refresh_token_reuse_detected result=blocked account_id=1004 family_id=fam_8a reused_token_id=rt_17 action=family_revoked ip=198.51.100.7 trace_id=8c71ee02토큰 원문은 로그에 남기지 않는다. token id, family id, hash prefix, result, reason을 남긴다.
실전 판단 기준
- 같은 refresh token을 두 번 쓰면 두 번째 요청이 거부되는가?
- access token 탈취와 refresh token 탈취는 폐기 범위가 다르다.
- refresh token rotation은 새 token 발급 기능이 아니라 탈취 재사용을 잡는 탐지 장치다.
테스트 포인트
- reuse 감지 시 token family 전체가 폐기되는가?
- race condition에서 정상 사용자가 과도하게 로그아웃되지 않는 정책이 있는가?
- refresh token 원문이 DB, 로그, trace에 저장되지 않는가?
- 비밀번호 변경과 계정 잠금 후 refresh가 거부되는가?
- access token TTL과 refresh token TTL이 제품 위험도에 맞게 짧은가?
위험 신호!
- refresh token을 DB에 원문으로 저장한다.
- refresh token을 회전하지 않고 만료일까지 계속 재사용한다.
- refresh endpoint에 rate limit와 reuse detection 로그가 없다.
- access token 수명이 너무 길어 탈취 후 피해 기간이 크다.
- 권한 변경 후 기존 access token claim이 오래 살아 있다.
- 동시 refresh 요청 정책이 없어 정상 사용자가 불규칙하게 로그아웃된다.
확인 질문
확인 질문
- refresh token rotation의 목적은 무엇인가?
- 탈취된 refresh token이 재사용될 때 이를 탐지하고 token family를 폐기하기 위해서다.
- access token과 refresh token의 수명을 다르게 두는 이유는 무엇인가?
- API 호출 credential의 피해 시간을 줄이면서 사용자 경험은 refresh 경계에서 통제하기 위해서다.
- token family가 필요한 이유는 무엇인가?
- 한 기기나 로그인 흐름에서 이어진 refresh token 묶음을 한 번에 폐기하고 재사용 탐지를 추적하기 위해서다.