JWT fallback
ISMS 대응으로 장기 Access Token을 단기화하고, Refresh Token·kid 키 회전·구버전 폴백·위젯 self-refresh까지 적용한 과정을 정리
30일 JWT를 5분으로 줄이면서 로그아웃을 피한 방법
ISMS 심사에서 앱 로그인 토큰의 긴 유효기간과 서명 키 교체 방식이 계속 지적됐다. 특히 키를 교체하더라도 기존 토큰을 바로 무효화하지 않도록 폴백 키를 운영하는 구조가 권고 사항으로 나왔다.
기존에는 로그인할 때 발급한 JWT 하나를 30일 동안 사용했다. 구현은 단순했지만 토큰이 유출되면 만료될 때까지 막을 방법이 없었고, 로그아웃을 해도 서버에서 바로 폐기할 수 없는 구조였다.
처음에는 JWT 만료시간을 30일에서 5분으로 바꾸면 될 거라고 생각했다. 그런데 당시에는 Refresh Token이 없었다. 만료시간만 줄이면 사용자는 로그인한 지 5분 만에 모든 API에서 401을 받게 된다.
문제는 앱만 고치면 끝나는 것도 아니었다. Android와 iOS뿐 아니라 WebView 브릿지와 위젯도 같은 토큰을 사용하고 있었고, 이미 발급된 30일짜리 JWT와 구버전 앱도 당분간은 계속 동작해야 했다.
결국 만료시간 하나를 바꾸는 작업이 아니라, 기존 사용자를 로그아웃시키지 않으면서 인증 구조를 바꾸는 작업이 됐다.
아래 코드는 구조를 설명하기 위해 이름과 세부 구현을 단순화했다.
Access Token을 짧게 가져가기 위해 Refresh Token을 추가했다
JWT는 서버가 별도의 세션을 저장하지 않아도 서명과 만료시간으로 사용자를 확인할 수 있다. 반면 발급한 토큰을 만료 전에 회수하기는 어렵다. 기존 구조에서는 30일짜리 JWT가 API 인증에 그대로 쓰였기 때문에, 토큰이 유출되면 최대 30일 동안 사용할 수 있었다.
이를 줄이기 위해 토큰의 역할을 나눴다.
| 구분 | 수명 | 역할 |
|---|---|---|
| Access Token | 5분 | API 요청 시 사용자 인증 |
| Refresh Token | 14일 | 만료된 Access Token 재발급 |
API 요청에는 수명이 짧은 Access Token을 사용하고, 만료되면 Refresh Token으로 새 Access Token을 발급받는다. 사용자는 로그인 상태를 유지할 수 있고, 유출된 Access Token이 사용될 수 있는 시간은 5분으로 줄어든다.
여기서 가장 오래 고민한 부분은 Refresh Token을 서버에서 관리할지 여부였다. JWT를 사용한 이유 중 하나는 서버에 인증 상태를 저장하지 않아도 된다는 점인데, Redis에 Refresh Token을 넣으면 결국 서버 리소스와 상태에 의존하게 된다. JWT의 장점을 일부 포기하는 선택이라 쉽게 결정하기 어려웠다.
반대로 Refresh Token까지 서버가 관여하지 않는 JWT로 발급하면, 탈취되거나 로그아웃한 토큰을 만료 전에 폐기하기 어렵다는 문제가 그대로 남는다. 그래서 API 인증은 5분짜리 Access Token으로 stateless하게 유지하고, 수명이 긴 Refresh Token만 Redis에서 통제하는 쪽을 선택했다.
Refresh Token은 JWT가 아닌 256bit 난수로 만들었다. Redis에는 원본을 저장하지 않고 SHA-256으로 해시한 값만 저장했으며, TTL은 14일로 설정했다.
refresh:{tokenHash} -> userId
TTL -> 14일
클라이언트가 보낸 값을 다시 해시해 Redis의 키와 비교하는 방식이다. Redis의 값이 노출되더라도 그 값 자체를 Refresh Token으로 사용할 수 없고, 로그아웃할 때 해당 키를 삭제하면 재발급도 바로 막을 수 있다.
기존 JWT는 kid가 없었다
토큰 구조를 바꾸면서 서명 키도 교체해야 했다. 새로 발급하는 JWT 헤더에는 어떤 키로 서명했는지 구분할 수 있도록 kid를 넣었다.
{
"alg": "HS256",
"typ": "JWT",
"kid": "v2"
}
서버는 kid를 보고 검증에 사용할 키를 선택한다. 새 토큰은 v2 키로 발급하면서도, 기존 v1 토큰이 남아 있는 동안에는 이전 키를 함께 유지할 수 있다. 키를 바꾼다고 모든 사용자를 한 번에 로그아웃시킬 필요가 없어진다.
문제는 이미 발급된 기존 JWT에는 kid가 없었다는 점이다. 새 검증 로직만 적용하면 이 토큰들은 어떤 키로 검증해야 할지 알 수 없다. ISMS 심사에서 권고받은 폴백 키 구조를 적용하되, 아무 토큰이나 이전 키로 검증하지 않도록 kid가 없는 기존 토큰에 한해서만 폴백 키를 사용했다.
public Key resolveSigningKey(JwsHeader<?> header) {
String kid = header.getKeyId();
if (kid == null || kid.isBlank()) {
return legacySigningKey;
}
String secret = keys.get(kid);
if (secret == null) {
throw new JwtException("unknown kid");
}
return toSigningKey(secret);
}
알 수 없는 kid까지 기존 키로 검증하면 안 된다. kid가 없으면 과거에 발급한 토큰으로 보고 기존 키를 사용하지만, 서버에 등록되지 않은 kid가 들어오면 잘못된 토큰으로 처리했다.
이전 키를 무기한 허용한 것은 아니다. 기존 30일 토큰이 남아 있는 기간에만 사용할 수 있도록 검증 범위를 제한했고, 이후 키 교체는 kid로 신·구 키를 명확하게 구분할 수 있게 했다.
앱과 위젯에도 갱신 로직이 필요했다
로그인 응답은 WebView에서 받은 뒤 네이티브 브릿지를 통해 Android와 iOS로 전달하고 있었다. 기존에는 Access Token 하나만 넘기면 됐지만, 변경 후에는 Refresh Token도 같이 전달하고 저장해야 했다. 일반 로그인과 SNS 로그인, 자동 로그인 경로를 모두 확인했다.
위젯은 별도로 처리해야 했다. 약 30분마다 데이터를 조회하는데 Access Token은 5분이면 만료되므로, 위젯이 API를 호출할 때는 대부분 만료된 상태다. 메인 앱의 네트워크 로직도 그대로 사용할 수 없어 위젯 안에 토큰 갱신 처리를 따로 추가했다.
클라이언트에서는 다음 순서로 동작하도록 했다.
- Access Token으로 API를 호출한다.
- 만료로 인한
401이면 Refresh API를 호출한다. - 새 Access Token을 저장한다.
- 실패했던 요청을 한 번만 다시 보낸다.
재시도는 한 번으로 제한했다. Refresh Token까지 만료된 상태에서 계속 갱신을 시도하거나, Refresh API 호출 자체가 다시 인증 인터셉터를 타면서 반복되는 상황을 막기 위해서다. Refresh API는 기존 인증 인터셉터가 붙지 않은 별도 클라이언트로 호출했다.
여러 API가 동시에 401을 받는 경우도 있었다. 각 요청이 모두 Refresh API를 호출하지 않도록 첫 번째 요청만 갱신을 진행하고, 나머지는 새 토큰이 발급될 때까지 기다린 뒤 이어서 처리했다.
신버전 앱이 구 API를 만나는 경우
서버와 앱은 동시에 배포할 수 없다. 앱을 먼저 배포하면 신버전 앱이 아직 구버전 API 서버에 연결되는 구간이 생긴다.
구 API는 로그인 응답에 Refresh Token을 내려주지 않고 /token/refresh 경로도 없다. 이를 모두 장애로 처리하면 앱을 미리 배포할 수 없기 때문에, 배포 기간에만 아래 두 경우를 호환 처리했다.
| 구버전 환경 | 신버전 앱의 처리 |
|---|---|
| 로그인 응답에 Refresh Token이 없음 | 기존 Access Token 방식으로 동작 |
Refresh API가 404를 반환 | 구 API로 판단하고 추가 갱신 중단 |
Refresh Token이 만료됐거나 검증에 실패한 401까지 넘기지는 않았다. 이 경우에는 정상적으로 재로그인하도록 했다. 구버전 환경이라고 판단할 수 있는 경우만 폴백 대상으로 둔 것이다.
서버에서는 kid가 없는 기존 JWT를 처리하고, 앱에서는 Refresh Token을 지원하지 않는 구 API를 처리했다. 적용 위치는 달랐지만 둘 다 새 구조로 넘어가는 동안 이전 버전과의 연결을 유지하기 위한 장치였다.
만료시간은 가장 마지막에 줄였다
서버 코드를 먼저 배포하면서 Access Token을 바로 5분으로 줄이면 구버전 앱은 5분 뒤부터 API를 사용할 수 없다. 그래서 만료시간을 코드에 고정하지 않고 설정으로 분리했다.
app:
jwt:
access-token-validity-minutes: 43200
refresh-token-validity-days: 14
current-kid: v1
Refresh Token과 kid, 레거시 검증 코드를 먼저 배포하되 Access Token의 수명은 기존과 같은 30일로 유지했다. 실제 전환 순서는 다음과 같았다.
- 서버에 신규·레거시 인증을 모두 처리하는 코드 배포
- WebView, Android, iOS, 위젯 배포
- 강제 업데이트로 구버전 앱 축소
- 설정값을
43200에서5로 변경
코드 배포와 만료시간 변경을 분리해두니 앱 버전별 동작을 확인한 뒤 정책을 적용할 수 있었고, 문제가 생기면 설정값만 되돌릴 수도 있었다.
마치며
이번 작업으로 Access Token의 유효기간을 30일에서 5분으로 줄였다. Refresh Token은 Redis에서 관리해 로그아웃 시 바로 폐기할 수 있게 했고, kid를 추가하면서 다음 키 교체부터는 기존 토큰을 유지한 채 전환할 수 있게 됐다.
Refresh Token rotation과 재사용 탐지, 모바일 저장소 암호화는 아직 남아 있다. 특히 위젯이 앱과 토큰 저장소를 공유하고 있어 저장 위치만 바꾸는 것으로는 끝나지 않았고, 기존 데이터 이전과 접근 권한도 함께 봐야 했다.
Access Token의 만료시간을 5분으로 바꾸는 것 자체는 어렵지 않았다. 오히려 가장 오래 고민한 것은 Refresh Token을 Redis에서 관리하면서 JWT의 stateless한 장점을 어디까지 포기할 것인지였다. 최종적으로는 짧은 Access Token은 그대로 stateless하게 사용하고, 탈취 시 영향이 더 큰 Refresh Token만 서버에서 통제하는 선에서 절충했다.
폴백 키도 배포 편의를 위해 임의로 추가한 기능은 아니었다. ISMS 심사에서 키 교체 시 기존 사용자를 모두 로그아웃시키지 않으면서도 신·구 키를 안전하게 운영할 방법으로 반복해서 권고받은 사항이었다. 그래서 기존 토큰에만 제한적으로 이전 키를 적용하고, 새 토큰부터는 kid로 검증 키를 명확히 구분하도록 구현했다.