Skip to content

JWT Refresh Token 탈취 감지와 Reuse Detection

JWT Refresh Token 탈취 감지와 Reuse Detection
목차

개인 AI 비서를 운영하면서 자동 갱신 스크립트와 CLI가 같은 인증 정보를 갱신하던 시기에 401 오류가 반복됐습니다. 갱신 주체를 줄이는 방향으로 대응했습니다. 다만 외부 인증 서버의 구현이나 로그를 확인한 것은 아닙니다. 이 관측만으로 특정 제공자가 재사용 감지 후 모든 세션을 폐기했다고 단정할 수는 없습니다.

이 경험을 계기로, 직접 인증 서버를 만든다면 Refresh Token(RT)의 소비·폐기·동시 요청을 어떤 경계로 묶어야 하는지 정리했습니다. 아래 코드는 설계를 설명하는 예제이며 실제 운영 인증 서버의 원본 코드는 아닙니다.

서명 검증과 재사용 감지는 다릅니다

Access Token은 API 접근에, RT는 새 토큰 발급에 사용합니다. 유효기간은 서비스 정책입니다. 서명과 만료만 검사하는 RT는 이전에 소비됐는지 알 수 없으므로, rotation과 재사용 감지를 하려면 토큰 계열과 소비 상태를 서버에서 추적해야 합니다. 원문 대신 안전한 해시를 저장하는 방식을 검토할 수 있습니다.

재사용은 탈취의 증거가 아니라 의심 신호입니다. 정상 클라이언트의 동시 갱신이나 응답 유실 후 재시도도 같은 모양으로 보입니다. 또 RT를 폐기해도 이미 발급된 AT는 별도 폐기 검사나 짧은 만료 정책이 없으면 계속 유효할 수 있습니다.

폐기한 뒤 예외를 던지면 폐기도 롤백될 수 있습니다

이 글의 이전 예제는 하나의 @Transactional 안에서 revokeAll()을 호출하고 SecurityException을 던졌습니다. 같은 DB 트랜잭션이라면 기본 Spring 규칙에서 이 unchecked exception이 폐기 작업까지 롤백할 수 있습니다.

실패 응답과 상태 변경의 커밋을 분리해야 합니다. 다음은 하나의 방법입니다. 예외를 던지지 않는 결과 객체로 트랜잭션을 끝내고, 커밋이 성공한 뒤 바깥에서 응답을 결정합니다.

// 구조 예제: repository 메서드와 Outcome/TokenPair 정의는 생략했습니다.
@Service
@RequiredArgsConstructor
public class RefreshFacade {
    private final RefreshTransaction transaction;

    public TokenPair refresh(String rawToken) {
        Outcome outcome = transaction.rotate(hash(rawToken)); // 별도 Spring 빈
        // 이 호출이 정상 반환했을 때 DB 트랜잭션은 커밋됨
        if (outcome.isRejected()) {
            throw new InvalidTokenException("갱신할 수 없는 토큰");
        }
        return outcome.tokens();
    }
}

@Service
@RequiredArgsConstructor
public class RefreshTransaction {
    private final RefreshTokenRepository tokens;

    @Transactional
    public Outcome rotate(String tokenHash) {
        // 같은 토큰의 검사와 소비를 원자적으로 수행하도록 SELECT ... FOR UPDATE.
        RefreshToken token = tokens.findByHashForUpdate(tokenHash).orElse(null);
        if (token == null || token.isExpired() || token.isRevoked()) {
            return Outcome.rejected();
        }
        if (token.isUsed()) {
            tokens.revokeFamily(token.getFamilyId());
            return Outcome.rejected(); // 예외로 폐기 갱신을 되돌리지 않음
        }
        token.markAsUsed();
        TokenPair pair = issueAndPersistSuccessor(token);
        return Outcome.accepted(pair);
    }
}

핵심은 @Transactional이라는 이름보다 실제 프록시 경계와 저장소 계약입니다. 같은 클래스 내부 호출로 분리한 척하면 적용되지 않을 수 있습니다. Redis의 원자적 스크립트를 쓰는 경우에는 DB 트랜잭션과 다른 실패 경계가 생기므로 별도로 설계해야 합니다.

위 예제도 전체 인증 구현은 아닙니다. 만료·사용자 비활성·토큰 계열 폐기 상태를 발급 전에 확인하고, 같은 계열의 서로 다른 토큰이 동시에 처리될 때도 발급과 폐기가 직렬화되도록 계열 행 잠금 또는 원자적 상태 전이를 설계해야 합니다. 보관 기간, 해시 조회, 로그 마스킹, rate limit도 필요합니다.

정상적인 동시 갱신은 클라이언트부터 줄입니다

같은 RT로 두 요청이 오면 첫 요청만 소비하도록 조건부 갱신 또는 행 잠금을 사용합니다. 클라이언트에서는 진행 중인 갱신 Promise를 공유하는 single-flight와 탭 간 조정을 먼저 검토합니다.

짧은 grace period 동안 이전 응답을 재전달하는 방법도 있지만, 시간 차만으로 정상 재시도와 탈취를 구분하지는 못합니다. 공격자도 그 시간 안에 재사용할 수 있습니다. 허용한다면 같은 후속 토큰을 재전달하는 멱등 정책, 응답의 암호화 보관·TTL, 계열 폐기와의 경쟁 조건을 함께 정해야 합니다. 재요청마다 새로운 RT를 발급하면 계열이 갈라집니다.

제 개인 시스템에서 확인한 범위

자동 갱신 스크립트와 CLI의 갱신 동작이 겹쳤고, 이후 인증 실패가 반복됐습니다. 대응은 갱신 책임을 한 곳에 두는 방향이었습니다. 만료보다 일찍 갱신하는 임계값 조정은 특정 버전에서 경합을 줄일 수 있지만, 외부 CLI의 갱신 조건이 바뀌면 다시 겹칠 수 있어 상호 배제를 보장하는 해법은 아닙니다.

이 관측과 직접 구현할 서버의 설계는 구분해야 합니다. 제공자의 내부 정책을 추정해 보안 보장처럼 소개했던 이전 표현을 정정합니다.

구현을 검증할 때 볼 결과

  • 같은 RT로 동시에 두 요청을 보내도 후속 토큰이 정책대로 한 번만 발급되는가.
  • 재사용 탐지로 실패 응답을 보낸 뒤 새 트랜잭션에서 읽어도 계열이 폐기돼 있는가.
  • DB 커밋 실패 시 토큰을 클라이언트에 성공 응답으로 내보내지 않는가.
  • 응답 유실 후 재시도와 계열 폐기가 경쟁해도 폐기된 계열에서 새 토큰을 발급하지 않는가.
  • RT 원문과 발급 응답이 로그·공용 캐시에 남지 않는가.

Spring의 롤백 규칙과 프록시 기반 트랜잭션의 내부 호출 제약을 함께 확인하면 예외 응답과 커밋을 혼동하지 않을 수 있습니다.

읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github