본문 바로가기
Q&A2026년 7월 3일1분 읽기

JWT 리프레시 토큰 회전(rotation) 넣었더니 가끔 강제 로그아웃됩니다

a
auth_seongmin
조회 716댓글 4

보안 강화하려고 리프레시 토큰 회전을 구현했습니다. 액세스 토큰 만료되면 리프레시 토큰으로 새 토큰 쌍을 받고, 쓴 리프레시 토큰은 폐기하는 방식이요. 그런데 멀쩡히 쓰던 사용자가 가끔 갑자기 로그아웃됩니다. 재현이 잘 안 되고 어쩌다 한 번씩이라 미치겠어요. 특히 앱을 여러 탭으로 켜놓은 사용자가 많이 겪는 것 같습니다.

댓글 4

김김영삼4개월 전

증상(여러 탭 + 간헐적)만 봐도 원인이 거의 확실합니다. 리프레시 토큰 회전의 경쟁 상태(race condition)예요.

무슨 일이 벌어지나

탭 A와 탭 B가 거의 동시에 토큰 만료를 감지하면:

  1. 탭 A가 리프레시 토큰 R로 갱신 요청 → 서버가 R 폐기, 새 토큰 R2 발급
  2. 탭 B도 같은 R로 갱신 요청 → 서버: "R은 이미 폐기됐는데? 탈취 의심!" → 세션 전체 무효화 → 강제 로그아웃

즉, 정상 사용자인데 회전 로직이 "재사용 공격"으로 오인하는 겁니다.

해결 방법들
  • 짧은 유예 기간(grace window) — 폐기된 직후의 토큰이라도 몇 초~수십 초 안의 재사용은 "동시 요청"으로 보고 같은 새 토큰을 돌려줍니다. 가장 실용적입니다.
  • 갱신 요청 단일화 — 프런트에서 여러 탭·요청이 동시에 갱신하지 않도록 잠급니다. 한 탭만 갱신하고 나머지는 그 결과를 공유(BroadcastChannel이나 공유 워커, 또는 갱신 중이면 대기).
  • 재사용 감지는 유지하되 정교하게 — 유예 기간을 넘긴 진짜 재사용만 탈취로 판정. 진짜 공격 방어는 살리면서 오탐만 없앱니다.
권장 조합

서버에 짧은 grace window를 두고, 프런트에서 갱신 요청을 하나로 합치는 것 — 둘 다 하면 거의 사라집니다. 완전 무상태를 고집하기보다, 리프레시 토큰은 서버(또는 Redis)에서 상태를 관리하는 게 이런 제어에 유리합니다.

aauth_seongmin(guest)3개월 전
프런트에서 갱신 요청 합치기를 하려는데, 저희는 axios 인터셉터로 401 뜨면 갱신하는 구조입니다. 이 경우 여러 요청이 동시에 401 맞으면 다 각자 갱신하러 가는데, 어떻게 하나로 묶나요?
김김영삼3개월 전

딱 그 상황이 제일 흔합니다. 갱신 프로미스를 하나만 만들어 공유하는 패턴으로 풀어요. 이미 갱신 중이면 새로 요청하지 않고 그 프로미스를 기다리게 하는 겁니다.

let refreshing = null

async function refreshToken() {
// 이미 갱신 중이면 그 프로미스를 재사용
if (!refreshing) {
refreshing = doRefresh().finally(() => { refreshing = null })
}
return refreshing
}

// axios 인터셉터
api.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true
await refreshToken() // 동시 401이 와도 실제 갱신은 1회
return api(error.config) // 원 요청 재시도
}
return Promise.reject(error)
})

핵심은 refreshing 변수 하나로 동시에 여러 요청이 401을 맞아도 갱신은 딱 한 번만 나가게 하는 겁니다. 나머지는 그 결과를 기다렸다가 새 토큰으로 재시도하고요. 여기에 서버 grace window까지 있으면 여러 탭 상황도 커버됩니다.

aauth_seongmin(guest)3개월 전
공유 프로미스 패턴으로 바꾸고 서버에 30초 grace 넣으니 이틀째 로그아웃 리포트가 0건입니다. 재현이 안 돼서 계속 헤맸는데 원인을 정확히 짚어주셔서 살았습니다 🙏
Ctrl+Enter로 등록