이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 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=8c71ee01
level=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 묶음을 한 번에 폐기하고 재사용 탐지를 추적하기 위해서다.

참고 문서