포스트

토큰을 훔쳐도 못 쓰게 만들 수 있을까

토큰을 훔쳐도 못 쓰게 만들 수 있을까

MVP 단계 - 일단 돌아가게 만들었다

초기 인증 로직을 설계할때는 단순하게 설계했다.

로그인하고 API만 호출하면 된다.

그래서 가장 단순한 JWT로 구현했다.

1
로그인 -> Access Token 발급 -> localStorage 저장 -> 만료 전까지 사용

테스트 하면서 발견한 문제

사용자가 한 로그아웃이 ‘서버 기준’으로는 아무 일도 일어나지 않는다.

로그아웃은 프론트에서 이렇게 처리했다.

1
localStorage.removeItem('accessToken');

화면은 로그인 페이지로 돌아가고, 브라우저에서도 토큰은 사라진다.

그런데 Access Token 기반 인증이 제대로 동작하는지 검증하기 위해

통합 테스트를 작성하면서 이상한 점을 발견했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void 로그아웃_후_기존_토큰으로_API_호출하면_실패해야한다() throws Exception {
    // given
    String accessToken = loginAndGetAccessToken();

    // when
    logout(accessToken);

    // then
    mockMvc.perform(get("/api/v1/posts")
            .header("Authorization", "Bearer " + accessToken))
            .andExpect(status().isUnauthorized());
}

이 테스트의 의도는 아래와 같다.

로그아웃을 했으면, 기존 토큰은 더 이상 유효하면 안된다.

그런데 결과는 예상과 달랐다.

로그아웃 이후에도 기존 Access Token으로 API 호출이 그대로 성공했다.

왜 이런일이 발생했을까?

곰곰히 생각해보니 문제는 구조 자체에 있었다.

JWT는 stateless하다.

서버는 토큰의 서명과 만료 시간만 검증한다.

  • 서명이 맞고
  • 만료 시간이 지나지 않았다면

그 토큰은 여전히 정상적인 인증 수단 이다.

프론트에서 토큰을 지웠다는 사실은 서버에게 아무런 의미가 없다.

즉, 내가 생각한 로그아웃은 사용자 기준 로그아웃이었고 서버 기준으로는 아무런 상태 변화가 없었다.

그래서 구조를 다시 생각해봤다

조금만 생각해보니, JWT는 Stateless하기 때문에 서버가 사용자의 로그아웃을 인지못하는건 사실 당연한 이야기였다. 그래서 이 문제를 해결하기 위해 두가지 방법을 생각해서 고민했다.

  1. Access Token을 아주 짧게 만든다.
  2. 서버가 “뭔가를 기억”하도록 만든다.

Access Token을 아주 짧게 만든다.

장점

  • 토큰이 짧게 죽으니까 탈취당해도 공격자가 쓸 수 있는 시간이 짧다.
  • 블랙리스트 같은 별도 무효화 메커니즘 없이도 피해를 어느 정도 줄일 수 있다.

단점

  • 만료가 짧으면 사용자가 아무것도 안 했는데 갑자기 로그인이 풀린다.
  • 글 작성처럼 시간이 걸리는 작업 중에 튕기면 작성 내용이 날아갈 수도 있다.
  • 만료를 5분으로 잡아도 그 5분 안에는 로그아웃한 토큰이 여전히 유효하다.

이 방법은 사실 서버가 사용자의 로그아웃을 인지 못하는 문제를 해결하는 방법이 아닌, 단순 피해시간만 줄이는 방법이기 때문에 선택하지 않았다.

서버가 “뭔가를 기억”하도록 만든다.

장점

  • 서버가 직접 토큰 상태를 관리하니까 로그아웃을 즉시 반영할 수 있다.
  • 탈취된 토큰도 서버에서 강제로 무효화할 수 있고, Rotation 같은 탈취 감지 메커니즘도 붙일 수 있다.
  • 보안 측면에서 훨씬 촘촘하게 제어가 가능하다.

단점

  • JWT의 장점인 stateless를 포기하게 된다.
  • 서버가 상태를 들고 있어야 하니까 매 요청마다 저장소를 조회해야 한다.
  • 서버가 여러 대로 늘어나면 저장소를 공유해야 하고, 그 저장소가 단일 장애 지점이 될 수 있다.
  • 구현 복잡도도 올라간다.

나는 이 방법을 선택했다.

그럼 어떻게 기억하게 할까?

세션 기반으로 전환

로그아웃 즉시 무효화 처리는 가능하나, 서버 확장 시 세션 공유 문제가 생긴다.

결국 JWT를 사용하는 의미가 희미해진다.

발급된 모든 토큰 저장

실제로 무효화가 필요한 건 로그아웃된 토큰 뿐인데, 발급된 모든 토큰을 저장하는건 낭비라고 생각했다.

로그아웃한 토큰만 블랙리스트에 올린다

평소에는 아무것도 저장하지 않다가 로그아웃이 발생했을 떄만 해당 토큰을 블랙리스트에 저장한다.

이후 요청에서 해당 토큰이 블랙 리스트에 있으면 차단한다.

나는 이 방법을 선택했다.

블랙리스트에 올린 토큰은 언제 지울까? - Redis 도입

Access Token은 만료 시간이 있다.

만료된 토큰은 어차피 더 이상 인증 수단이 아니다.

그렇다면 블랙리스트도 토큰의 남은 유효시간까지만 유지하면 된다.

이제 남은 건 하나였다.

이 데이터를 어디에 저장을 할 것인가?

내가 고민한건 MySQL과 Redis였다.

MySQL에 저장한다면?

1
2
3
4
5
CREATE TABLE token_blacklist (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    token VARCHAR(500),
    expired_at DATETIME
);

요청이 들어올때마다 해당 토큰이 블랙리스트 테이블에 있는지 조회 후 있으면 차단하면 된다.

하지만 MySQL은 적합하지 않다고 생각했다.

왜냐하면

  • 매 요청마다 DB 조회 → 부하 증가
  • 만료된 데이터 정리를 위해 스케줄러 필요
  • 디스크 기반 I/O → InMemory에 비해 상대적으로 느림

Redis에 저장한다면?

1
2
3
redisTemplate.opsForValue()
    .set(accessToken, "logout",
         remainingTime, TimeUnit.MILLISECONDS);

Redis는 자체적으로 TTL(Time To Live) 기능을 지원한다.

토큰의 남은 유효시간만큼 TTL을 설정해두면

Access Token이 만료되는 시점에 맞춰 블랙리스트 데이터도 자동으로 삭제된다.

즉,

  • 만료 시점에 자동 정리
  • 별도의 스케줄러 구현 불필요
  • 조회 속도 빠름 (O(1))
  • 서버 여러 대 환경에서도 공유 가능

이라는 장점을 자연스럽게 가져갈 수 있다.

1
2
3
4
5
6
7
8
  // 로그아웃 시
  Duration remainingTtl = jwtTokenProvider.getRemainingExpiry(accessToken, TokenType.ACCESS);

  // Access Token의 남은 유효 시간 -> Redis의 TTL 설정으로 자동 삭제 처리 예정
  if (!remainingTtl.isZero()) {
      tokenBlacklistRepository.add(accessToken, remainingTtl);
  }

특히 블랙리스트는 짧은 생명주기를 가진 상태 데이터이기 때문에

디스크 기반 RDB보다는 메모리 기반 저장소가 더 적합하다고 판단했다.

물론 Redis는 별도의 인프라 관리가 필요하다는 단점이 있다.

하지만 추후 분산락을 사용할 예정이였기 때문에 , 어차피 Redis 인프라는 도입할 생각이였다.

Access Token 만료시 사용자가 그대로 튕겨나간다? - RefreshToken 도입

문제점

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("MVP 구조의 문제: 토큰이 만료되면 사용자가 그냥 튕겨 나간다")
void 만료_전엔_되다가_만료_후엔_안된다() throws Exception {

    // given: 로그인해서 토큰 받기
    String accessToken = 로그인_후_토큰_받기("test@test.com", "password123");

    // 만료 전: 정상 동작
    mockMvc.perform(get("/api/v1/members/me")
            .header("Authorization", "Bearer " + accessToken))
            .andExpect(status().isOk());  

    // 토큰 만료까지 대기
    Thread.sleep(10);

    // 만료 후: 401 → 사용자는 이 순간 로그인 화면으로 튕겨 나간다
    mockMvc.perform(get("/api/v1/members/me")
            .header("Authorization", "Bearer " + accessToken))
            .andExpect(status().isUnauthorized());  
}

해당 테스트를 진행해보니 테스트 통과가 나왔다.

사실 만료 후에는 당연히 API 요청 시 401 에러 코드를 받아야되는게 맞지만,

사용자 입장에서는 게시물을 작성하고 있는 상황일 수 도 있다.

그런데 어느 순간 API가 401을 반환하고 프론트에서는 로그인 화면으로 이동하게 된다.

해결 방법

이 문제를 해결하기 위해 AccessToken이 만료되었을 때 자동으로

갱신 해주는 별도 토큰(RefreshToken)을 도입했다.

  • Access Token: 30분 수명, 실제 API 인증에 사용
  • Refresh Token: 7일 수명, Access Token 만료 시 재발급용

Access Token이 만료되면 조용히 Refresh Token으로 새 Access Token을 받아오고,

사용자는 로그인이 풀렸는지도 모르게 계속 서비스를 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
public ReissueResult reissue(String incomingToken) {

    Long memberId = jwtTokenProvider.getMemberIdFromJWT(incomingToken, TokenType.REFRESH);

    RefreshToken storedToken = refreshTokenRepository.findByMemberId(memberId)
            .orElseThrow(() -> new InvalidRefreshTokenException("로그아웃된 사용자입니다."));

    // 단순 비교
    if (!storedToken.getToken().equals(incomingToken)) {
        throw new InvalidRefreshTokenException("유효하지 않은 토큰입니다.");
    }

    // 새 Access Token 발급
    String newAccessToken = jwtTokenProvider.generateAccessToken(memberId, null);

    return new ReissueResult(newAccessToken, storedToken.getToken());
}

RefreshToken 재사용이 막히지 않는다…?

테스트 코드를 작성하는 중 이런 생각이 들었다.

RefreshToken이 탈취되면 그럼 무한으로 AccessToken을 발급 받을 수 있는 거 아닌가?

그래서 바로 테스트 코드를 작성해서 결과를 확인해보았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("동일 Refresh Token으로 재발급이 무한히 가능하다")
void 동일_Refresh_Token_으로_유효기한내에_무제한으로_토급_발급이_가능하다() {
    // given
    String refreshToken = 로그인_후_리프레시_토큰_받기();

    // when
    ReissueResult first = authService.reissue(refreshToken);
    ReissueResult second = authService.reissue(refreshToken);
    ReissueResult third = authService.reissue(refreshToken);

    // then
    assertThat(first.accessToken()).isNotBlank();
    assertThat(second.accessToken()).isNotBlank();
    assertThat(third.accessToken()).isNotBlank();

    // (추가) Access Token이 매번 새로 발급되는지도 확인 가능
    assertThat(first.accessToken()).isNotEqualTo(second.accessToken());
    assertThat(second.accessToken()).isNotEqualTo(third.accessToken());
}

테스트를 돌려보니 통과가 나왔다. 이 결과가 의미하는 것은 아래와 같았다.

Refresh Token이 한 번 유출되면 만료될 때까지 누구든지 Access Token을 계속 발급받을 수 있다.

특히 아래와 같은 상황이 발생할 수 있다.

  • 사용자의 Refresh Token이 탈취된다.
  • 공격자는 그 값을 복사해둔다.
  • 사용자는 정상적으로 재발급을 한 번 진행한다.
  • 공격자는 여전히 같은 Refresh Token으로 계속 재발급을 요청한다.

현재 구조에서는 이걸 막을 방법이 없었다.

그래서 재발급 구조를 바꾸었다.

RefreshToken 탈취를 대비한 Rotation 도입

재발급이 발생할 때마다 Refresh Token을 그대로 두지 않고,

새로운 Refresh Token을 발급하면서 기존 값을 교체하도록 바꿨다.

즉, 한 번 재발급이 일어나면 이전 Refresh Token은 더 이상 사용할 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    /**
     * Refresh Token을 회전(rotating)한다.
     *
     * 현재 저장된 토큰과 전달된 토큰이 일치하는 경우에만
     * 새로운 토큰으로 교체한다.
     *
     * 이는 토큰 재사용(replay attack)을 방지할 수 있으며,
     * 탈취된 이전 토큰으로는 재발급이 불가능하도록 보장한다.
     *
     * @param incomingToken 클라이언트가 보유한 기존 Refresh Token
     * @param newToken 새로 발급된 Refresh Token
     * @throws TokenTamperedException 토큰이 일치하지 않는 경우
     */
    public void rotate(String incomingToken, String newToken) {
        validateMatch(incomingToken);
        this.token = newToken;
    }

    private void validateMatch(String incomingToken) {
        if (!this.token.equals(incomingToken)) {
            throw new TokenTamperedException("토큰이 탈취되었을 가능성이 있습니다. 다시 로그인해주세요.");
        }
    }

그럼 진짜로 막히는지 테스트로 확인해보았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
@DisplayName("Rotation 이후 이전 Refresh Token으로는 재발급이 불가능하다")
void Rotation_이후_이전_Refresh_Token으로는_재발급이_불가능하다() {

    // given
    String originalRefresh = 로그인_후_리프레시_토큰_받기();

    // 첫 재발급 → 토큰 교체
    ReissueResult first = authService.reissue(originalRefresh);
    String rotatedRefresh = first.refreshToken();

    // when & then
    assertThatThrownBy(() -> authService.reissue(originalRefresh))
            .isInstanceOf(TokenTamperedException.class);

    // 새 토큰은 정상 동작
    ReissueResult second = authService.reissue(rotatedRefresh);
    assertThat(second.accessToken()).isNotBlank();
}

테스트를 실행해보니,

  • 기존 Refresh Token은 예외 발생
  • 교체된 새로운 Refresh Token은 정상 동작

의도한 대로 동작했다.

항목Rotation 적용 전Rotation 적용 후
Refresh Token 유지 방식만료될 때까지 동일 토큰 유지재발급 시 새로운 토큰으로 교체
동일 토큰 재사용가능 (여러 번 재발급 가능)불가능 (이전 토큰은 즉시 무효)
탈취 시 영향 범위만료 시점까지 계속 재발급 가능한 번 사용 후 이전 토큰 차단
이상 상황 감지감지 불가이미 교체된 토큰이 들어오면 예외 발생
서버 저장 값거의 변하지 않음재발급마다 저장 값 갱신
보안 관점장기 이용권처럼 동작재발급과 함께 갱신되는 구조
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.