RefreshToken Opaque전환 - fresh-market/fm-backend GitHub Wiki

RefreshToken을 JWT에서 OpaqueToken으로 바꾼 이유

이 문서는 인증 토큰을 설계하면서 왜 AccessToken은 JWT를 유지하고 RefreshToken만 OpaqueToken으로 전환했는지, 그리고 그 과정에서 생긴 두 가지 문제(탈취 탐지 / 소유자 조회)를 어떻게 풀었는지 정리한 것이다.

1. JWT 구조 복습

JWT(Json Web Token)는 세션처럼 서버가 사용자 정보를 기억하는 대신, 사용자 정보를 담은 토큰을 클라이언트에 발급하고 요청마다 그 토큰을 함께 보내게 하는 방식이다.

구성 내용
Header 서명 알고리즘(HS256/RS256 등), 토큰 타입
Payload 발급 주체/발급 시간/만료 시간 등 메타데이터 + memberId 와 role 같은 커스텀 데이터. Base64 인코딩일 뿐 암호화가 아니다
Signature Header + Payload를 서버만 아는 비밀키로 서명한 값. 위조 여부만 검증하지, Payload 내용을 숨기지는 못한다

Payload가 암호화가 아니라 인코딩이라, 토큰을 탈취당하면 누구나 내용을 그대로 읽을 수 있다. 그래서 통상 세 가지 정책으로 위험을 낮춘다.

  1. 수명을 짧게 잡는다 (15분~1시간)
  2. Payload에 민감정보를 담지 않는다
  3. 비밀번호 변경, 결제처럼 민감한 작업에는 2차 확인을 둔다

2. AccessToken / RefreshToken 분리

토큰 수명을 짧게 잡으면 그만큼 자주 재로그인해야 하는 문제가 생긴다. 그래서 토큰을 두 개로 나눈다.

  • AccessToken: 매 요청 인증에 실제로 쓰인다. 민감정보만 빼고 최대한 많은 정보를 담고, 수명은 짧다.
  • RefreshToken: AccessToken이 만료됐을 때 재발급받는 용도. 최소한의 정보만 담고 수명은 길다(예: 7일 이상).

AccessToken이 없거나 만료된 요청이 오면, 서버는 RefreshToken이 유효한지 확인하고 유효하면 재로그인 없이 새 AccessToken을 내준다.

문제는 RefreshToken의 수명이 훨씬 길기 때문에, 탈취당했을 때 피해가 AccessToken 탈취보다 훨씬 크다는 점이다. 대신 RefreshToken은 AccessToken이 만료될 때만 조회되므로(사용자 1명당 AccessToken 수명 주기당 최대 1번), 서버에 저장해서 관리하는 부담이 크지 않다.

3. RefreshToken을 서버에 저장하기로 한 이유

그래서 AccessToken은 HttpOnly 쿠키로만 내려주는 무상태 JWT를 유지하고, RefreshToken은 서버(Redis)에 저장해서 직접 통제할 수 있게 했다. 무상태성이라는 JWT의 장점 하나를 RefreshToken 쪽에서만 포기하는 대신, 탈취 의심/회원 탈퇴/로그아웃 시 즉시 무효화할 수 있는 능력을 얻는 것이다.

Redis 장애에 대비해서 재발급 시 DB에도 토큰 해시를 백업해두고, Redis 조회가 실패하면 DB로 폴백하는 구조를 같이 설계했다.

4. RTR (Refresh Token Rotation)

RefreshToken을 서버가 들고 있게 된 김에, RTR(RefreshToken Rotation)을 도입했다. AccessToken을 재발급받을 때 RefreshToken도 함께 새로 발급하고 기존 것은 무효화하는 기법이다. 이렇게 하면 RefreshToken이 재발급에 쓰일 때마다 옛 토큰은 더 이상 쓸 수 없게 되어, 탈취당한 토큰의 유효기간을 사실상 "다음 정상 재발급 전까지"로 좁힐 수 있다.

5. AccessToken은 JWT, RefreshToken은 OpaqueToken

RTR을 도입하면서 RefreshToken을 서버에 저장하는 구조가 되고 보니, RefreshToken 입장에서는 JWT가 갖는 "서버가 기억할 필요 없다"는 장점이 이미 사라진 상태였다. 반면 OpaqueToken(불투명한 무작위 값)은 애초에 "토큰을 서버에 저장한다"는 전제로 설계되는 방식이라, 탈취당해도 토큰 자체에서 아무 정보도 읽을 수 없다는 점에서 더 안전하다.

그래서 다음과 같이 정리했다.

  • AccessToken: 여전히 JWT. 매 요청 검증에 서버 저장소 조회 없이 서명만으로 검증할 수 있어야 하므로 무상태성이 그대로 유효하다.
  • RefreshToken: OpaqueToken으로 전환. 어차피 서버가 저장하고 있고, 탈취당해도 내용이 읽히지 않는다는 이점이 있다.

6. OpaqueToken으로 전환하며 생긴 두 가지 문제

RefreshToken의 값이 memberId 같은 식별 가능한 값이 아니라 서버가 발급한 무작위 난수가 되면서, 두 가지 문제가 새로 생겼다.

  1. 공격자가 이미 회전되어 죽은 옛 토큰으로 재발급을 시도했을 때, 이게 탈취 시도라는 걸 어떻게 알아채는가?
  2. 탈취가 감지됐을 때, 그 토큰이 누구 것인 줄 알고 무효화하는가?

6-1. 저장 구조

RefreshToken 저장의 기본 키는 원본 난수 자체가 아니라 그 난수의 SHA-256 해시다.

refreshToken:{SHA-256(원본 난수)} -> "memberId|role|type|remember"

클라이언트가 쿠키로 원본 난수를 보내오면, 서버는 그 값을 그대로 키로 쓰는 게 아니라 다시 해시한 뒤 그 해시로 조회한다. 원본 난수를 키에 그대로 쓰면 Redis가 유출됐을 때 토큰 값이 그대로 노출되기 때문에, 반드시 해시를 한 번 거친다.

6-2. 문제 1, 탈취(재사용) 탐지: tombstone

JWT 시절에는 키가 refreshToken:{role}:{memberId}로 고정되어 있어서, 회전이 일어나도 같은 키의 값(해시)만 새로 덮어쓰면 됐다. 그러면 "같은 키를 조회했는데 값이 바뀌어 있다"는 사실 자체로 재사용(탈취) 여부를 판별할 수 있었다.

[JWT]
refreshToken:member:1 -> SHA-256(a1s2d3f4)
─ 재발급 ─
refreshToken:member:1 -> SHA-256(z5x6c7v8)
공격자가 옛 토큰(a1s2d3f4)으로 시도
  -> SHA-256(a1s2d3f4)를 현재 값 SHA-256(z5x6c7v8)과 비교 -> 불일치 -> 탈취 의심으로 처리

OpaqueToken은 키 자체가 토큰마다 다른 해시값이라, 회전이 일어나면 완전히 새로운 키(refreshToken:{새 해시})가 하나 더 생긴다. "같은 키의 값이 바뀌었는지"로 판별하는 방식을 그대로 쓸 수 없다는 뜻이다.

그래서 옛 키를 즉시 삭제하지 않고, 값 끝에 |REVOKED를 붙여 죽은 토큰이라는 표식(tombstone)을 남긴 채로 유지한다. 즉시 삭제하지 않는 이유는, 삭제해버리면 나중에 그 토큰이 재생(replay)됐을 때 "원래 없던 토큰"과 "이미 회전되어 죽은 토큰"을 구분할 방법이 없어져서, 탈취 여부 자체를 알 수 없게 되기 때문이다. tombstone의 만료 시간은 새로 정하지 않고 옛 키에 원래 남아있던 TTL을 그대로 재사용한다. 토큰이 원래 유효했을 남은 기간 동안은 언제든 재생될 수 있으므로, 그 기간만큼은 탐지 가능 상태를 유지해야 하기 때문이다.

[OpaqueToken]
refreshToken:{SHA-256(a1s2d3f4)} -> "1|ROLE_USER|MEMBER|false"
─ 재발급 ─
refreshToken:{SHA-256(a1s2d3f4)} -> "1|ROLE_USER|MEMBER|false|REVOKED"   <- 값 끝에 표식만 추가(필드 교체 아님)
refreshToken:{SHA-256(z5x6c7v8)} -> "1|ROLE_USER|MEMBER|false"           <- 새 토큰

공격자가 a1s2d3f4로 재발급을 시도하면, 서버는 SHA-256(a1s2d3f4) 키를 조회해 값 끝이 |REVOKED인 것을 확인하고 재사용(탈취) 시도로 판정한다.

6-3. 문제 2, 역방향 인덱스: 누구 것인지 알아내기

문제 1은 해결됐지만, 탈취가 감지된 뒤 "그 회원의 현재(진짜) RefreshToken"을 지워서 세션을 강제 종료하려면 여전히 문제가 남는다. 키가 memberId가 아니라 토큰 해시이기 때문에, memberId만 갖고는 그 회원의 현재 토큰을 바로 찾을 수 없다.

그래서 기본 레코드를 저장할 때, role과 memberId를 조합한 키로 "이 회원의 현재 토큰 해시가 뭔지"를 가리키는 역방향 인덱스를 하나 더 저장한다.

activeRefreshToken:{role}:{memberId} -> 현재 토큰 해시

정상적으로 재발급이 성공할 때마다 이 인덱스는 새 해시로 덮어쓰기만 하면 된다(별도 표식 없음. tombstone은 기본 레코드에만 있는 개념이다). 탈취가 감지되면, 이 인덱스로 그 회원의 "지금 살아있는" 토큰 해시를 찾아 기본 레코드를 지우고, 인덱스 자체도 삭제한다. 이때 RefreshToken만 지우는 게 아니라, 아직 만료되지 않은 AccessToken까지 전부 쓸 수 없게 만드는 별도의 무효화 기준 시각을 함께 기록해서, "그 회원의 모든 토큰"이 실질적으로 무효화되도록 한다.

7. 정리

  1. OpaqueToken은 키 자체가 토큰마다 다른 해시라, 재발급 때마다 새 키가 생긴다. JWT처럼 "같은 키의 값 변경"으로 탈취를 판별할 수 없어서, 옛 레코드를 즉시 삭제하지 않고 tombstone(|REVOKED 표식)으로 남긴다.
  2. 키가 회원 식별자가 아니라 토큰 해시이기 때문에, 탈취 감지 이후 "누구 것을 지워야 하는지" 알아낼 방법이 없다. role:memberId -> 현재 토큰 해시를 가리키는 역방향 인덱스를 별도로 유지해서 해결한다.
  3. 기본 레코드(토큰 해시 -> 소유자 정보)가 인증의 메인 경로이고, 역방향 인덱스는 토큰 없이 회원 기준으로 접근해야 하는 소수 케이스(로그아웃/탈퇴/강제 세션 종료)를 위한 보조 경로다.