refresh token 회전은 토큰 문자열을 바꾸는 기능이 아니라 하나의 계열에서 predecessor를 한 번만 소비하는 상태 전이입니다. 동시 요청과 응답 유실이 있으면 서버가 원문을 다시 만들 수 있는지, 정상 지연을 침해 신호와 어떻게 구분할지, access token의 잔여 수명을 어떻게 다룰지를 별도로 결정해야 합니다.
두 탭의 정상 갱신과 옛 토큰 재사용 판정
브라우저의 탭 A와 B가 같은 refresh token R7을 보관하고 있다가 동시에 갱신한다고 합시다. A가 먼저 성공해 R8을 받으면 B의 R7은 이미 소비된 토큰입니다. 서버 입장에서는 이 요청이 정상 탭의 늦은 갱신인지, 탈취한 R7을 재생한 것인지 bearer만 보고 완벽히 구분할 수 없습니다.
회전은 갱신 성공 때 이전 토큰을 소비하고 후속 토큰으로 바꾸는 과정입니다. 재사용 탐지는 이 전환 이력을 이용하지만, 정상 동시성과 응답 유실의 복구 정책까지 자동으로 결정해 주지는 않습니다.
상태 trace는 R7 active/version 7→한 worker commit→R7 consumed, R8 current/version 8입니다. 두 번째 worker가 검증을 먼저 끝냈더라도 commit 조건에서 탈락해야 하며, 이 조건이 애플리케이션 메모리 mutex가 아니라 공유 권위 저장소의 원자 경계에 있어야 여러 서버에서도 같은 결과가 납니다.
Refresh token 계열의 단일 상태 전이
family F를 한 기기 세션 S에서 회전으로 이어진 refresh token 계열이라고 두고, 현재 버전 7과 R7의 검증용 해시·만료·회수 상태를 저장한다고 합시다. 두 worker가 R7을 먼저 검증해도, 커밋 시점에는 current_version = 7인 상태만 조건부로 바꿔 하나만 성공시켜야 합니다. 다른 worker는 버전 불일치로 실패하며, 그 결과를 정상 동시 요청으로 볼지 재사용 의심으로 계열을 끊을지는 별도 정책으로 결정합니다.
begin transaction
load and lock family F
require active session S and active account version
require current_version = 7 and hash_matches(R7)
generate R8 and its verifier hash
mark R7 consumed; set current_version = 8
store hash(R8), expiry, predecessor relation
optionally store encrypted short-lived retry result
commit
return access_token, R8
이 코드는 상태 전이를 보여 주는 슈도코드입니다. 실제 토큰 생성·비교에는 암호학적 난수와 검증된 구현을 사용하고, DB 실패 시 응답으로 미커밋 토큰을 성공 반환하지 않습니다. 서로 다른 서버 인스턴스도 같은 권위 상태에 조건부 전환을 적용해야 합니다. 한 프로세스의 mutex로는 충분하지 않습니다.
그림을 글로 읽기
- F의 현재 토큰 R7
- 탭 A의 갱신
- 탭 B의 갱신
- 원자 전환 성공 · R8
- 이미 소비됨: 제한적 복구 또는 재인증
- F의 현재 토큰 R7 → 탭 A의 갱신: 동시 제출
- F의 현재 토큰 R7 → 탭 B의 갱신: 동시 제출
- 탭 A의 갱신 → 원자 전환 성공 · R8: 조건 충족
- 탭 B의 갱신 → 이미 소비됨: 버전 불일치
토큰 검증 해시와 발급 원문 재전달 한계
전환을 커밋한 뒤 A로 가는 응답이 유실되면 A는 여전히 R7만 압니다. 서버가 R8의 해시만 저장했다면 R8을 역산해 재전달할 수 없습니다. 해시는 후보 검증을 위한 것이지 발급 결과의 백업이 아닙니다.
| 복구 선택 | 필요한 상태 | 받아들이는 비용 |
|---|---|---|
| 재로그인 요구 | 소비 이력·회수 상태 | 정상 응답 유실에도 사용자 개입 |
| 제한적 동일 결과 재전달 | 암호화한 결과·요청 결합·짧은 TTL | 민감 원문 보관과 재생 창 |
| 옛 토큰 무제한 허용 | 사실상 회전 경계 없음 | 탈취 재생 탐지 약화로 부적절 |
재전달을 선택할 때는 서버가 원래 검증한 세션·계열·요청 ID·입력과 결과를 묶어 저장하고, 같은 요청 ID에 대해서만 암호화한 결과를 짧은 시간·횟수 안에 돌려줍니다. 요청 ID와 IP·User-Agent는 복제될 수 있으므로 이를 독립적인 기기 소유 증명으로 취급하지 않고, 더 강한 sender binding이 필요한지는 프로토콜 지원과 위협 모델로 따집니다. 암호화 결과 캐시는 별도 비밀처럼 키 접근 통제·폐기·로그 비노출까지 운영해야 합니다.
클라이언트 조정과 서버 최종 결정
탭 하나에서 refresh를 맡고 다른 탭이 완료를 기다리도록 브라우저의 공유 조정 수단을 사용할 수 있습니다. 그러나 메모리 변수 하나는 탭마다 따로 존재하고, 리더 탭 종료·메시지 유실·오래된 응답 도착이 생깁니다. 조정자를 사용해도 서버의 원자 소비 검사를 제거하지 않습니다.
클라이언트는 늦은 R7 기반 결과가 더 최신인 R9 상태를 덮지 않도록 요청 세대와 갱신 순서를 관리합니다. bearer 원문을 탭 간 메시지로 무조건 방송하지 말고 보관 구조와 노출 경계를 정합니다. 각 기기는 별도 family로 나눌 수 있어 다른 기기의 정상 갱신까지 하나로 직렬화할 필요는 없습니다.
의심 재사용과 회수 범위 결정
유예 범위를 벗어난 옛 토큰 사용이나 계열 복제 신호가 보이면 해당 family를 회수하고 재인증을 요구할 수 있습니다. 계정 전체 침해 근거가 있으면 더 넓게 회수합니다. 정상 동시 갱신 하나를 무조건 계정 전체 침해로 단정하면 사용성이 나빠지고, 반대로 모든 재사용을 정상이라고 허용하면 회전 목적을 잃습니다.
refresh family 회수는 이미 발급한 access token을 자동으로 없애지 않습니다. 즉시 차단이 필요한 API는 세션·계정 버전을 현재 상태와 비교하고, 그렇지 않은 서비스는 짧은 access token의 잔여 수명만큼 허용 지연을 인정해야 합니다.
커밋 직후 응답을 버리는 시험에서 클라이언트는 여전히 R7을 들고 있으므로, 서버가 요청 결합 결과를 짧게 보관하는 정책이면 같은 요청 ID에만 R8 결과를 돌려줘야 합니다. 그런 보관이 없으면 재로그인이 정직한 결과이며, R7을 무기한 다시 허용하는 방식은 회전의 predecessor 소비 보장을 약화시킵니다.
커밋 성공·응답 유실 복구 시험
두 worker가 R7 검증을 동시에 끝내도록 한 뒤 커밋 성공이 하나인지 확인합니다. 이어서 커밋 직후 응답을 버리고 다른 서버로 같은 요청을 재전송합니다. 정책이 재전달이면 동일한 결과만 제한적으로 돌아와야 하고, 재로그인이면 새 후속 계열이 생기지 않아야 합니다.
유예 만료·계열 회수·암호화 결과 만료·탭 리더 종료·늦은 응답 덮어쓰기를 따로 검사합니다. 로그에는 family·버전·판정 사유를 남기되 토큰과 결과 원문은 제외합니다. 여기서의 경쟁 순서는 설계 예제이며 실제 브라우저 인증 제품을 실행한 검증 결과는 아닙니다.