JWT 보안: XSS로 털린 리프레시 토큰, Rotation 전략으로 막아낸 실전 코드

최근 금융 도메인의 인증 시스템을 고도화하던 중, 모의 해킹(Penetration Test)에서 치명적인 시나리오가 보고되었습니다. 공격자가 XSS(Cross-Site Scripting) 취약점을 통해 사용자의 Refresh Token을 탈취했을 때, Access Token의 유효기간이 짧더라도 공격자는 탈취한 리프레시 토큰으로 끊임없이 새로운 액세스 토큰을 발급받아 세션을 유지할 수 있다는 점이었습니다.

일반적으로 리프레시 토큰은 액세스 토큰보다 긴 수명(2주~한 달)을 가지므로, 한 번 털리면 사용자가 로그아웃을 하더라도 공격자가 이미 보유한 토큰은 서버 측에서 제어할 방법이 없는 상태가 됩니다(Stateless의 딜레마). 이 글에서는 이 문제를 해결하기 위해 적용한 Refresh Token Rotation 기법과, 그 과정에서 겪은 동시성 문제(Race Condition) 해결 과정을 엔지니어 관점에서 상세히 기술합니다.

보안 취약점 분석: 왜 만료 시간만으로는 부족한가?

기존 시스템은 OAuth 2.0 표준을 준수한다고 했지만, 구현상의 편의를 위해 리프레시 토큰을 한 번 발급하면 만료될 때까지 계속 재사용하는 구조였습니다. 이는 웹 보안 관점에서 'Replay Attack'에 매우 취약합니다.

당시 서버 환경은 Spring Boot 3.2에 Redis를 세션 스토리지로 사용하고 있었으며, 일일 활성 사용자(DAU) 5만 명 규모의 트래픽을 처리하고 있었습니다. 로그 분석 결과, 비정상적인 IP에서 동일한 리프레시 토큰으로 액세스 토큰 갱신을 시도하는 패턴이 감지되었으나, 서버는 토큰 자체의 서명(Signature)과 유효기간만 검증했기에 이를 막을 수 없었습니다.

Critical Risk: 리프레시 토큰이 HttpOnly 쿠키에 저장되어 있더라도, 공격자가 사용자의 브라우저를 제어하거나 네트워크 단에서 스니핑에 성공한다면, 해당 토큰이 만료될 때까지 계정은 완전히 탈취된 상태입니다.

초기 접근의 실패: 단순 블랙리스트

처음에는 단순히 "사용자가 로그아웃할 때 해당 리프레시 토큰을 블랙리스트에 넣자"라고 생각했습니다. Redis에 Blacklisted_Token:xyz 형태로 저장하고, 요청 시마다 체크하는 방식입니다. 하지만 이 방식은 근본적인 해결책이 아니었습니다.

이유는 간단합니다. "공격자가 훔친 토큰으로 갱신 요청을 할 때, 원본 사용자는 아직 로그아웃을 하지 않았기 때문"입니다. 사용자가 인지하지 못한 상태에서 백그라운드로 토큰이 갱신되면 블랙리스트는 무용지물이 됩니다. 따라서 우리는 토큰을 사용할 때마다 폐기하고 새로 발급하는 Rotation 전략으로 선회해야 했습니다.

해결책: Refresh Token Rotation 및 재사용 감지

핵심 로직은 다음과 같습니다. 클라이언트가 리프레시 토큰(R1)을 사용하여 갱신을 요청하면, 서버는 새로운 액세스 토큰과 새로운 리프레시 토큰(R2)을 발급합니다. 그리고 기존 R1은 무효화합니다.

여기서 가장 중요한 포인트는 "이미 사용된 토큰(R1)으로 다시 요청이 들어왔을 때"의 처리입니다. 이는 토큰 탈취를 의미하므로, 해당 사용자의 모든 토큰 체인(Token Family)을 즉시 무효화하여 공격자와 원본 사용자 모두의 접속을 끊어야 합니다. 그래야 원본 사용자가 "로그인이 풀렸네?" 하고 다시 로그인하여 새로운 보안 컨텍스트를 생성하게 됩니다.

// Redis 엔티티 설계 (Hash 구조 권장)
@RedisHash(value = "refreshToken", timeToLive = 1209600) // 2주
public class RefreshToken {
    
    @Id
    private String tokenValue;
    
    @Indexed
    private String username;
    
    private String familyId; // 토큰 추적을 위한 그룹 ID
    
    // 이미 사용된 토큰인지 마킹 (Rotation 핵심)
    private boolean isUsed; 

    // Constructor & Getters...
}

// 서비스 레이어 로직
@Transactional
public TokenResponse rotateToken(String requestRefreshToken) {
    RefreshToken currentToken = redisRepository.findById(requestRefreshToken)
            .orElseThrow(() -> new TokenNotFoundException("유효하지 않은 토큰"));

    // 1. 재사용 감지 로직 (Re-use Detection)
    if (currentToken.isUsed()) {
        // 보안 위협 감지! 해당 유저의 모든 토큰 폭파
        redisRepository.deleteAllByUsername(currentToken.getUsername());
        
        log.error("Security Alert: Re-used token detected. User {} blocked.", currentToken.getUsername());
        throw new SecurityBreachException("토큰 탈취가 의심되어 로그아웃 처리됩니다.");
    }

    // 2. 토큰 사용 처리
    currentToken.setUsed(true);
    redisRepository.save(currentToken);

    // 3. 새로운 토큰 발급 (Rotation)
    String newRefreshTokenValue = jwtProvider.createRefreshToken(currentToken.getUsername());
    RefreshToken newToken = new RefreshToken(
            newRefreshTokenValue, 
            currentToken.getUsername(), 
            currentToken.getFamilyId(), // Family ID 유지
            false
    );
    redisRepository.save(newToken);

    String newAccessToken = jwtProvider.createAccessToken(currentToken.getUsername());
    
    return new TokenResponse(newAccessToken, newRefreshTokenValue);
}

위 코드에서 isUsed 플래그가 핵심입니다. 기존 토큰을 즉시 삭제하지 않고 "사용됨" 상태로 남겨두는 이유는, 추후 공격자가 옛날 토큰을 제시했을 때 이를 감지하고 deleteAllByUsername을 트리거하기 위함입니다. 이를 통해 JWT 보안 레벨을 비약적으로 상승시킬 수 있습니다.

성능 및 보안 효과 분석

이 전략을 적용한 후, Redis의 메모리 사용량과 보안 지표를 모니터링했습니다. Rotation을 적용하면 Redis 쓰기 연산이 증가하므로 이에 대한 부하 테스트가 필수적입니다.

지표 (Metric) 기존 (Static Token) 개선 (Rotation)
토큰 탈취 시 생존 시간 만료 시까지 (최대 14일) 다음 갱신 시점 즉시 차단
Redis Write OPS 낮음 (로그인 시 1회) 중간 (갱신 주기마다 발생)
동시성 제어 복잡도 없음 높음 (Race Condition 주의)

Redis의 Write 부하가 약 15% 증가했지만, 이는 보안 강화로 인한 트레이드오프로 충분히 감내할 만한 수준이었습니다. 가장 큰 성과는 토큰이 탈취되더라도 공격자가 한 번이라도 토큰을 사용하면 원본 사용자의 다음 요청 시(혹은 반대로 원본 사용자가 사용 후 공격자가 시도 시) 즉시 모든 세션이 파기된다는 점입니다.

GitHub: JWT Rotation 전체 소스 코드 보기

주의할 점: 프론트엔드 동시성 문제 (Concurrency)

이 구현에서 개발자들이 가장 많이 겪는 문제는 바로 프론트엔드의 비동기 요청 경합입니다. 예를 들어, 웹 페이지 로드 시 /api/user/api/notifications API를 동시에 호출한다고 가정해 봅시다. 두 요청 모두 액세스 토큰이 만료되어 401을 받으면, 프론트엔드의 인터셉터(Interceptor)가 각각 리프레시 토큰 갱신 요청을 보낼 수 있습니다.

이때 Race Condition이 발생합니다. 1. 요청 A가 리프레시 토큰(R1)을 보냄. 2. 요청 B도 리프레시 토큰(R1)을 보냄. 3. 요청 A 처리 완료 -> R1은 Used 상태가 되고 R2 발급. 4. 요청 B 처리 시작 -> R1은 이미 Used 상태이므로 토큰 탈취로 간주하고 로그아웃 처리.

정상적인 사용자가 페이지를 새로고침했다가 튕겨 나가는 끔찍한 UX가 발생합니다. 이를 해결하기 위해 서버 측에서 "Grace Period(유예 기간)"를 두거나, 프론트엔드에서 mutex를 사용하여 토큰 갱신 요청이 진행 중일 때는 다른 요청을 대기시키는 로직(Token Refresh Queue)을 반드시 구현해야 합니다. 제 경험상 서버에서 5~10초 정도의 유예 기간을 두어, Used 상태라도 아주 짧은 시간 내의 재요청은 허용하는 것이 가장 현실적인 타협안이었습니다.

Best Practice: 클라이언트 측에서는 axios interceptor 레벨에서 변수를 두어 갱신 요청 중인지 확인(Locking)하고, 서버 측에서는 짧은 유예 시간을 두어 네트워크 레이턴시로 인한 오탐지를 방지하십시오.

결론

JWT 기반의 인증 시스템에서 Refresh Token Rotation은 선택이 아닌 필수입니다. 단순히 토큰을 발급하고 검증하는 것을 넘어, 토큰의 생명주기를 능동적으로 관리하고 비정상적인 접근을 탐지하는 로직이 포함되어야 진정한 의미의 보안 시스템이라 할 수 있습니다. 오늘 소개한 로직을 여러분의 프로젝트에 적용하여 XSS 위협으로부터 사용자 데이터를 안전하게 보호하시기 바랍니다.

Post a Comment