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

  • access token 탈취와 refresh token 탈취는 대응 범위가 어떻게 다른가?
  • refresh token rotation에서 재사용 탐지가 왜 중요한 사고 신호인가?
  • 강제 로그아웃, token 폐기, 사용자 재인증을 어떤 순서로 적용해야 하는가?

개요

Token 탈취 대응은 어떤 token이, 어느 계정에서, 어느 기기와 client에서, 언제부터 위험했는지를 좁히는 작업이다.

Access token은 짧게 살아도 탈취 직후 민감 API를 호출할 수 있다.

Refresh token은 새 access token을 계속 발급받을 수 있으므로 token family와 기기 세션 단위로 폐기해야 한다.

사고 대응에서 가장 나쁜 상태는 token 원문이 로그에 남아 있고, 어떤 token을 폐기해야 하는지 서버가 식별하지 못하는 상태다.

공격 시나리오

  • 공격자는 XSS, 악성 확장, 프록시, 모바일 단말 탈취, 로그 유출을 통해 token을 얻는다.
  • Access token만 얻은 경우 짧은 시간 안에 프로필, 주문, 결제 수단 같은 민감 API를 호출한다.
  • Refresh token을 얻은 경우 정상 사용자보다 먼저 rotation을 수행해 기존 token을 무효화시키거나 장기 접근을 유지한다.
  • 재사용 탐지가 없으면 같은 refresh token이 두 위치에서 쓰여도 서버가 구분하지 못한다.
  • 사용자 logout만 처리하면 다른 기기나 같은 token family에 남은 refresh token이 계속 살아 있을 수 있다.

취약한 요청/응답 예시

POST /oauth/token HTTP/1.1
Content-Type: application/json
 
{
  "grant_type": "refresh_token",
  "refresh_token": "rt_live_eyJhbGciOi..."
}
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "access_token": "new-access-token",
  "refresh_token": "new-refresh-token"
}
INFO refresh success user=1004 refresh_token=rt_live_eyJhbGciOi... ip=203.0.113.10

원문 refresh token을 로그에 남기고 재사용 여부를 검사하지 않으면 로그 유출이 곧 계정 탈취로 이어진다.

주제별 핵심 판단

  • Access token 사고는 TTL, scope, audience, 호출된 API 범위로 영향도를 본다.
  • Refresh token 사고는 token family, rotation 상태, 기기 세션, 마지막 정상 사용 시각으로 판단한다.
  • Token 저장소가 token hash만 갖고 있어야 폐기와 감사가 가능하면서도 원문 유출을 줄일 수 있다.
  • 재사용 탐지는 이미 회전된 refresh token이 다시 들어오는 순간을 사고 신호로 본다.
  • 전 계정 강제 로그아웃은 마지막 수단이며, 먼저 위험 client, 위험 token family, 위험 기간을 좁힌다.
  • 사용자에게는 재로그인 이유와 필요한 조치를 명확히 안내해야 한다.

개선된 요청/응답 예시

HTTP/1.1 401 Unauthorized
Content-Type: application/json
Cache-Control: no-store
X-Request-Id: req-4d8a11ff
 
{
  "error": "session_invalidated",
  "message": "보안을 위해 다시 로그인해 주세요."
}
level=WARN event=token.refresh.reuse_detected actor=member:1004 client=web familyId=rtf_91a2 previousTokenHash=sha256:7b31 result=blocked action=family_revoked traceId=req-4d8a11ff
level=INFO event=session.device.revoked actor=system target=member:1004 deviceId=device-7 reason=refresh_reuse_detected traceId=ir-20260701-002

Token 원문 대신 hash, family id, client id, device id를 남겨 폐기 범위를 추적한다.

방어 설계

  • Access token은 짧은 TTL과 제한된 audience/scope를 가진다.
  • Refresh token은 rotation하고, 이전 refresh token 재사용을 사고 이벤트로 처리한다.
  • 서버 저장소에는 refresh token 원문이 아니라 검증 가능한 hash와 family id를 저장한다.
  • 기기별 세션 목록을 유지해 특정 기기 또는 특정 client만 폐기할 수 있게 한다.
  • 비밀번호 변경, MFA 재등록, 계정 탈취 의심 시 관련 token family를 폐기한다.
  • 중요 API는 최근 인증 시간 또는 step-up 인증을 요구할 수 있어야 한다.
  • Logout API는 현재 세션 종료와 전체 기기 로그아웃을 구분한다.
  • Token 폐기 이벤트는 캐시, gateway, resource server 검증 지연을 고려해 전파 상태를 확인한다.

Spring/HTTP 예시

@Transactional
public TokenPair rotateRefreshToken(String presentedToken, DeviceContext device) {
    var tokenHash = tokenHasher.sha256(presentedToken);
    var stored = refreshTokenRepository.findByHash(tokenHash)
        .orElseThrow(() -> new InvalidTokenException("unknown refresh token"));
 
    if (stored.isRotated() || stored.isRevoked()) {
        refreshTokenRepository.revokeFamily(stored.familyId());
        audit.warn("token.refresh.reuse_detected", Map.of(
            "actor", stored.memberId(),
            "familyId", stored.familyId(),
            "deviceId", stored.deviceId(),
            "result", "blocked"
        ));
        throw new SessionInvalidatedException();
    }
 
    stored.markRotated();
    return tokenIssuer.issueNextPair(stored.memberId(), stored.familyId(), device);
}

Rotation은 새 token 발급 기능이 아니라 재사용 탐지 장치다.

동시 요청 race condition이 있으면 정상 사용자의 중복 요청을 공격으로 오판할 수 있으므로 저장소 갱신은 원자적으로 처리한다.

운영 로그와 감사 지점

level=INFO event=token.family.revoked actor=security target=member:1004 familyId=rtf_91a2 reason=incident_response affectedDevices=2 traceId=ir-20260701-002
level=INFO event=token.introspection.denied tokenHash=sha256:7b31 reason=revoked_family resourceServer=order-api traceId=req-8123a112
  • token 발급, rotation, 재사용 탐지, 폐기, introspection 실패를 별도 이벤트로 남긴다.
  • 로그에는 token 원문을 남기지 말고 token hash, family id, device id, client id를 남긴다.
  • Resource server가 폐기된 token을 언제부터 거부했는지 확인할 수 있어야 한다.
  • 재사용 탐지 후 사용자 알림, 기기 로그아웃, 비밀번호 변경 요구가 실제로 실행되었는지 남긴다.
  • 강제 로그아웃 대상 계정 수와 실패 계정 수를 따로 집계한다.

실전 판단 기준

  • 단일 access token 노출이면 TTL 만료까지의 호출 범위를 우선 산정한다.
  • Refresh token 노출이면 같은 family와 기기 세션 전체를 의심한다.
  • Token 폐기는 사용자 UX보다 피해 확산 차단이 우선인 경우가 많다.
  • 전 계정 로그아웃은 강하지만 증거가 부족한 상태에서 쓰면 업무 장애가 된다.
  • JWT만 쓰면 서버 폐기가 어렵기 때문에 짧은 TTL과 별도 revocation 전략이 필요하다.
  • Token 저장 위치가 localStorage라면 XSS 사고와 함께 평가해야 한다.
  • 대응 완료는 token 폐기 명령 실행이 아니라 resource server 거부 로그 확인까지 포함한다.

테스트 포인트

  • 회전된 refresh token을 다시 제출하면 token family가 폐기되는지 확인한다.
  • 현재 기기 로그아웃과 전체 기기 로그아웃이 다른 범위로 동작하는지 확인한다.
  • 비밀번호 변경 후 기존 refresh token이 거부되는지 확인한다.
  • Resource server 캐시 때문에 폐기된 access token이 계속 통과하지 않는지 확인한다.
  • Token 폐기 로그에 원문 token이 남지 않는지 확인한다.
  • 두 개의 동시 refresh 요청에서 race condition이 발생해 정상 사용자를 잠그지 않는지 확인한다.

위험 신호!

  • Refresh token을 DB와 로그에 원문으로 저장한다.
  • Logout이 현재 브라우저 세션만 지우고 서버 token 저장소를 갱신하지 않는다.
  • Token family, device id, client id가 없어 폐기 범위를 좁힐 수 없다.
  • 재사용 탐지 없이 refresh token rotation만 구현되어 있다.
  • JWT 폐기를 만료될 때까지 기다린다로만 처리한다.
  • 강제 로그아웃 후 resource server에서 계속 허용되는 token이 있는지 확인하지 않는다.

확인 질문

확인 질문

  • Refresh token 재사용 탐지가 사고 신호인 이유는 무엇인가?
    • 회전된 token은 정상 경로에서 다시 쓰이면 안 되므로, 같은 token이 재등장하면 탈취나 race condition을 의심해야 한다.
  • Token 사고에서 로그에 남겨야 할 식별자는 무엇인가?
    • token hash, family id, device id, client id, actor, result, reason, trace id다.
  • 강제 로그아웃의 완료 기준은 무엇인가?
    • 폐기 대상 token이 resource server와 gateway에서 실제로 거부되는 로그까지 확인하는 것이다.

참고 문서