Access Token이 만료됐을 때 새로 발급받기 위한 장수명 토큰.
왜 토큰을 두 개로 나누나
하나만 쓰면 딜레마에 빠진다.
- 수명을 길게 — → 탈취당하면 오랫동안 악용된다
- 수명을 짧게 — → 사용자가 자주 로그인해야 한다
두 개로 나누면 둘 다 해결된다.
| Access Token | Refresh Token | |
|---|---|---|
| 수명 | 짧게 (5~30분) | 길게 (2주~1개월) |
| 용도 | 매 API 요청에 첨부 | 재발급 요청에만 |
| 노출 빈도 | 높음(모든 요청) | 낮음(가끔) |
| 저장 위치 | 메모리 | HttpOnly 쿠키 |
| 서버 보관 | 안 함 | 한다 (무효화용) |
핵심 논리 — 자주 돌아다니는 것은 탈취 위험이 크니 짧게 만들고, 오래 사는 것은 거의 안 돌아다니게 한다.
흐름
다이어그램 로딩 중…
Rotation — 재발급 시 Refresh도 새로 준다
❌ Refresh는 그대로 두고 Access만 새로 발급
- 탈취된 Refresh가 2주 내내 유효
✅ Refresh도 매번 새것으로 교체하고 옛것은 폐기
- 탈취해도 정상 사용자가 한 번 갱신하면 무효화된다
재사용 탐지 — Rotation의 진짜 힘
폐기된 Refresh Token이 다시 사용되면 무엇을 뜻하는가?
정상 사용자가 이미 갱신 → 옛 토큰은 폐기됨 그런데 그 옛 토큰이 들어왔다
- 누군가 옛 토큰을 갖고 있다 = 탈취되었다는 증거
대응 — 해당 사용자의 모든 토큰 계열(family)을 즉시 무효화하고 재로그인을 요구한다. 공격자와 정상 사용자를 구분할 수 없으므로 둘 다 끊는다.
이것이 OAuth 2.0 보안 모범 사례(BCP)가 권장하는 방식이다.
어디에 저장하나
클라이언트 — HttpOnly + Secure + SameSite 쿠키.
Path=/auth/refresh 로 제한하면 일반 API 요청에는 아예 실려 가지 않아
노출 면적이 더 줄어든다.
서버 — 반드시 저장한다(Redis 등).
- key — : refresh:{userId}:{tokenId}
- value : { deviceInfo, issuedAt, family }
- TTL — : 만료 시각
서버가 보관해야 무효화할 수 있다. Refresh Token까지 무상태로 만들면 "강제 로그아웃 불가"라는 JWT의 약점이 그대로 남는다.
동시 갱신 경쟁 — 실무에서 자주 겪는 버그
Access 만료 시점에 여러 API 요청이 동시에 401을 받음
→ 각각 Refresh로 재발급 시도
→ Rotation 때문에 첫 번째만 성공, 나머지는 "폐기된 토큰" 판정
→ 재사용 탐지에 걸려 전부 로그아웃 ✗
대응
- 클라이언트에서 갱신 요청을 하나로 묶는다(진행 중이면 그 결과를 공유)
- 서버에서 짧은 유예 시간(수 초)을 둬 직전 토큰도 허용
로그아웃
- Access Token — : 서버가 모르므로 만료를 기다리거나 블랙리스트 Refresh Token : 서버 저장소에서 삭제 → 재발급 불가
Access의 수명이 짧아야 로그아웃이 실질적으로 동작한다. 15분 이상으로 늘리면 "로그아웃했는데 15분간 API가 된다" 는 상태가 된다.