모든 사용자를 로그아웃시키지 않고 JWT 서명 키를 교체하는 방법
JWT 서명 키를 단순하게 교체하면 모든 활성 토큰이 무효화되고 모든 사용자가 로그아웃됩니다. 이를 방지하는 중첩 기간(overlap window)과 `kid` 디자인이 여기에 있습니다.
JWT는 서버만 보유한 키로 서명되었기 때문에 신뢰할 수 있습니다. 해당 키가 유출되면 누구나 유효한 토큰을 위조할 수 있으므로, 정기적으로 키를 변경해야 합니다. 단순한 방법인 이전 키를 새 키로 교체하는 방식은, 아직 사용 중인 모든 토큰을 무효화하고 모든 로그인한 사용자가 다음 요청 시 강제로 로그아웃되게 만듭니다. 이 게시물에서는 강제 로그아웃 없이 키를 교체하는 설계를 보여줍니다. 여러 개의 키를 한 번에 활성 상태로 유지하고, 가장 최신 키로 서명하며, 이전 토큰이 자체적으로 만료될 때까지 모든 키로 검증하는 방식입니다.
핵심 아이디어는 간단합니다. 서명과 검증이 반드시 동일한 단일 키를 사용할 필요는 없습니다. 서명은 현재 키인 단 하나의 키로만 수행하지만, 검증은 키 집합을 대상으로 수행합니다. 토큰이 해당 집합에 여전히 존재하는 키로 서명되었다면, 그 토큰은 유효한 상태를 유지합니다. 따라서 키 교체는 집합에 새 키를 추가하고, 서명자를 새 키로 옮긴 다음, 이전 키로 서명된 것이 더 이상 유효하지 않게 된 후에야 이전 키를 제거하는 문제가 됩니다.
단순한 키 교체가 모든 사용자를 로그아웃시키는 이유
수명이 24시간인 토큰을 발급한다고 가정해 봅시다. 어느 시점에서든 유효한 토큰의 롤링 집단이 존재하며, 일부는 몇 초 전에, 일부는 거의 24시간 전에 발급된 것입니다. 이들 모두는 발급될 당시 유효했던 키로 서명되었습니다.
이제 키를 교체합니다. 이전 키로 서명된 토큰을 포함한 다음 요청이 도착하면, 서버는 새 키로 이를 확인하려고 시도하지만 서명이 일치하지 않아 확인에 실패합니다. 사용자는 로그아웃된 것처럼 보입니다. 이는 교체 전에 발급된 모든 토큰, 즉 전체 활성 사용자 기반에 해당하는 모든 토큰에 발생합니다. 의도적으로 누군가를 폐기한 것이 아닙니다. 단지 토큰을 검증 가능하게 만들었던 하나의 비밀을 변경했을 뿐이며, 되돌릴 방법은 없었습니다.
토큰의 수명이 핵심입니다. 이전 키로 서명된 토큰은 교체 후에도 전체 수명 동안 유효하게 유지될 수 있습니다. 따라서 이전 키는 최소한 그 기간 동안은 확인용으로 계속 사용할 수 있어야 합니다. 바로 이 한 가지 관찰이 전체 설계의 핵심입니다.
해결책: 하나의 키로 서명하고 여러 키로 검증하기
키를 두 가지 역할로 분할합니다.
- 서명은 하나의 키, 즉 현재 키를 사용합니다. 모든 새 토큰은 이 키로 서명됩니다.
- 검증은 키 세트, 즉 현재 키와 해당 키의 토큰이 아직 유효할 수 있는 모든 이전 키를 사용합니다.
토큰이 들어오면 현재 키로 서명되었다고 가정하지 않습니다. 어떤 키가 서명했는지 알아내고 해당 키로 검증합니다. 서명 키가 아직 세트에 있다면 토큰은 유효합니다. 이미 폐기한 키로 서명되었다면 검증은 실패하며, 이는 완전히 사라진 키에 대해 정확히 원하는 결과입니다.
첫 번째 단계를 저렴하게 만들기 위해 모든 키는 kid(키 ID)라는 짧은 식별자를 갖습니다. 서명할 때 토큰 헤더에 kid를 찍습니다. 검증할 때 헤더에서 kid를 읽고 해당 키를 정확히 조회합니다. kid가 없으면 작동하는 키를 찾을 때까지 세트의 모든 키를 시도해야 하므로 더 느리고 약간의 타이밍 정보가 유출됩니다. kid를 사용하면 검증 키 조회가 단일 맵 읽기가 됩니다.
다음은 kid가 있는 JWT의 헤더입니다. 헤더는 단지 base64url로 인코딩된 JSON이므로 서명 확인 전에 읽을 수 있습니다.
{
"alg": "HS256",
"kid": "2026-07-01"
}
kid는 키별로 고유하고 안정적인 어떤 것이든 될 수 있습니다. 날짜, 임의의 문자열 또는 카운터 모두 작동합니다. 유일한 규칙은 다른 활성 키와 충돌해서는 안 되며, 누구나 읽을 수 있으므로 비밀을 담고 있어서는 안 된다는 것입니다.
교체 타임라인, 단계별 설명
교체는 단일 전환이 아니라 일련의 상태입니다. 각 상태는 머무르기에 안전하므로, 타이머로 전환을 자동화하고 동기화된 재시작을 조정할 필요가 없습니다.
| 단계 | 서명 키 | 검증 세트 | 진행 상황 |
|---|---|---|---|
| 안정 | K1 | {K1} | 하나의 키만 존재하며, 모든 토큰은 K1으로 서명됩니다 |
| 도입 | K1 | {K1, K2} | K2가 세트에 추가되지만 아직 아무것도 서명하지 않습니다 |
| 전환 | K2 | {K1, K2} | 새 토큰은 K2를 사용하고, 이전 K1 토큰은 여전히 검증됩니다 |
| 중첩 | K2 | {K1, K2} | 마지막 K1 토큰이 만료되기를 기다립니다 |
| 폐기 | K2 | {K2} | K1이 제거되고, 해당 토큰은 어차피 모두 사라졌습니다 |
중요한 한 가지 기간은 중첩 기간입니다. K1은 서명을 중단한 시점부터 계산하여 최대 토큰 수명만큼은 검증 세트에 남아 있어야 합니다. 토큰의 수명이 24시간이고 정오에 K2로 전환했다면, 가장 최신의 K1 토큰은 정오 직전에 발행되어 다음 날 정오 직전에 소멸됩니다. K1을 그보다 일찍 제거하면 합법적으로 발급된 토큰이 무효화됩니다. 해당 기간이 지난 후에 제거하면 활성 토큰 중 어느 것도 K1에 의존하지 않으므로, 폐기는 사용자에게 아무런 영향을 미치지 않습니다.
어떤 시점에서도 검증 세트가 활성 토큰에 필요한 것보다 작아지지 않는다는 점에 주목하세요. 이것이 사용자 측에서 교체가 보이지 않게 유지하는 방법입니다. 사용자는 교체로 인한 서명 실패를 절대 보지 않으며, 오직 이미 만료된 토큰으로 인한 실패만 보게 됩니다.
최소한의 Go 구현
다음은 여러 키를 보유하고, 현재 키로 서명하며, kid로 검증하는 키링입니다. 간결성을 위해 HMAC(HS256)을 사용하지만, 검증 세트는 공개 키를 보유하고 서명자만 개인 키를 보유하는 비대칭 키(RS256, ES256)에서도 동일한 형태가 작동합니다.
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type key struct {
id string
secret []byte
notAfter time.Time // stop using for verification after this
}
type Keyring struct {
current string // kid of the signing key
keys map[string]*key // kid -> key, the verification set
}
func (r *Keyring) Sign(claims jwt.MapClaims) (string, error) {
k := r.keys[r.current]
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tok.Header["kid"] = k.id
return tok.SignedString(k.secret)
}
func (r *Keyring) Verify(raw string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
kid, ok := t.Header["kid"].(string)
if !ok {
return nil, errors.New("token has no kid")
}
k, ok := r.keys[kid]
if !ok {
return nil, errors.New("unknown or retired kid")
}
if time.Now().After(k.notAfter) {
return nil, errors.New("key past its verification window")
}
return k.secret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
return claims, err
}
두 가지 세부 사항이 실제로 중요합니다. jwt.WithValidMethods 옵션은 허용되는 알고리즘을 고정하여, 호출자가 alg를 none으로 설정하거나 RS256을 HS256으로 다운그레이드하는 고전적인 alg 혼동 공격을 차단합니다. 그리고 kid 조회는 기본값으로 대체하는 대신 알 수 없거나 폐기된 키에 대해 오류를 반환하므로, 의도적으로 제거한 키로 서명된 토큰은 안전하게 실패(fail-closed)합니다.
교체 자체는 동일한 구조에 대한 작은 변경입니다. 예약된 작업은 다음 키를 추가하고, 서명자를 전환하며, 유효 기간이 지난 모든 것을 제거합니다.
func (r *Keyring) Rotate(newKid string, secret []byte, tokenTTL time.Duration) {
// New key can verify tokens minted from now until now + tokenTTL,
// plus a margin so the last-minted token is safely covered.
r.keys[newKid] = &key{
id: newKid,
secret: secret,
notAfter: time.Now().Add(tokenTTL * 2),
}
r.current = newKid // sign with the new key from here on
// Drop keys whose verification window has passed.
for kid, k := range r.keys {
if time.Now().After(k.notAfter) {
delete(r.keys, kid)
}
}
}
여러 인스턴스에 걸친 로테이션 자동화
하나의 프로세스가 메모리에 키링을 유지하는 것은 쉽습니다. 실제 배포 환경에는 로드 밸런서 뒤에 여러 서버 인스턴스가 있으며, 이들 모두 어떤 키가 활성 상태인지에 대해 동의해야 합니다. 만약 인스턴스 A가 새 키로 교체하고 인스턴스 B가 이를 알지 못한다면, A가 서명한 토큰은 B에서 실패하게 되고, 다시 무작위 로그아웃 문제에 직면하게 됩니다.
해결책은 키링을 프로세스별 메모리 대신 공유 저장소에 보관하는 것입니다. Redis나 데이터베이스 행을 사용할 수 있습니다. 일반적으로 주 단위로 실행되는 예약된 작업이 새 키를 생성하고, kid 및 유효 기간과 함께 공유 저장소에 기록한 다음, 현재 포인터를 이동시킵니다. 각 인스턴스는 TTL이 있는 짧은 캐시나 pub/sub 알림을 통해 저장소에서 키 세트를 읽어오므로, 몇 초 내에 모든 인스턴스가 동일한 세트를 보게 됩니다.
실용적인 설정은 현재(current) 키와 다음(next) 키, 이렇게 두 개의 키를 항상 활성 상태로 유지하며, 유효 기간이 서로 겹치도록 합니다. 매주 실행되는 작업은 다음(next) 키를 현재(current) 키로 승격시키고 새로운 다음(next) 키를 생성합니다. 유효 기간이 토큰의 수명보다 길게 겹치기 때문에, 인스턴스가 유효한 토큰을 거부하는 공백이 발생하지 않습니다. 만약 세션이나 속도 제한을 위해 이미 Redis를 운영하고 있다면, 이 방식은 작은 키 하나와 cron과 유사한 작업 하나를 추가할 뿐, 더 무거운 작업은 아닙니다.
비대칭 키의 경우, 검증 세트를 노출하기 위한 잘 알려진 표준이 있습니다. 바로 JWKS입니다. 이는 알려진 URL에 위치한 JSON 문서로, kid별로 공개 키 목록을 나열합니다. 리소스 서버는 이를 가져와 캐시하고, 들어오는 토큰의 kid를 공개 키와 일치시킵니다. 토큰 헤더의 kid는 JWKS 항목의 인덱스가 되는 kid와 동일하며, 이것이 단일 서비스 설정에서도 이 식별자를 추가할 가치가 있는 이유입니다. 이것은 다른 서비스가 서명 키를 보유하지 않고도 여러분의 토큰을 검증할 수 있게 해주는 연결점입니다.
수용하게 되는 절충안
이 설계는 무중단 로테이션을 얻는 대신 대가가 따릅니다.
이제 하나의 키 대신 키 집합을 관리하게 됩니다. 즉, 키를 생성하고, 안전하게 저장하며, 인스턴스 간에 동기화하고, 제때에 제거해야 합니다. 제거 로직의 버그는 만료된 키를 남겨두거나(경미한 문제) 키를 너무 일찍 제거(로그아웃 이벤트 발생)할 수 있습니다. 공유 저장소는 인증 크리티컬 패스의 일부가 되므로, 이제 그 가용성이 로그인에 영향을 미칩니다.
중첩 기간은 편의성을 제공하는 만큼 보안 비용을 수반합니다. 중첩 기간 동안에는 이전 키도 여전히 유효하므로, 만약 의심되는 유출 때문에 키를 교체하는 것이라면 유출된 키는 해당 기간 동안 계속 작동합니다. 이 메커니즘만으로는 유효한 토큰을 존중하면서 동시에 유출된 키를 즉시 무효화할 수 없습니다. 노출을 줄이는 수단은 짧은 토큰 수명입니다. 더 긴 수명의 리프레시 토큰으로 갱신되는 15분에서 1시간 사이의 액세스 토큰 수명은, 서명 키가 유출되더라도 공격자에게 유용한 시간이 키 교체 후 최대 그 짧은 시간으로 제한됨을 의미합니다. 이것이 짧은 액세스 토큰 수명과 리프레시 토큰이 키 로테이션과 함께 사용되는 주된 이유입니다.
이 모든 것의 기저에는 상태 비저장(stateless)과 상태 저장(stateful) 간의 선택이 있습니다. 키 집합으로 JWT를 검증하는 것은 상태 비저장 방식입니다. 즉, 어떤 인스턴스든 메모리에 있는 키만으로 모든 토큰을 확인할 수 있으며, 요청당 데이터베이스 조회가 필요 없습니다. 이것이 바로 JWT의 매력입니다. 로그아웃이나 계정 탈취 등으로 인해 특정 토큰을 만료 전에 폐기해야 하는 순간, 상태가 필요해집니다. 즉, 모든 요청마다 확인해야 하는 토큰 ID의 거부 목록(deny list)이 필요합니다. 이는 여러분이 피하고자 했던 요청당 조회를 다시 도입하게 만듭니다. 많은 시스템이 일반적인 경로는 상태 비저장으로 유지하면서, 드물게 발생하는 강제 폐기 사례를 위해 작은 거부 목록을 허용합니다. 키 로테이션과 토큰별 폐기는 서로 다른 문제를 해결하며, 로테이션이 폐기 기능을 제공하지는 않습니다.
단일 키로 충분한 경우
프로토타입을 넘어선 모든 것에는 로테이션을 할 가치가 있지만, 위에서 설명한 메커니즘은 작거나 수명이 짧은 시스템에는 과할 수 있습니다. kid가 없고 키링도 없는 단일 키는 다음 조건이 모두 충족될 때 괜찮습니다.
- 서명 키가 서버 메모리나 보안 암호 관리자에만 존재하고 안전하지 않은 채널을 통해 전송되지 않아 유출 가능성이 거의 없는 경우.
- 토큰 수명이 짧고 갱신 경로가 있어서, 모든 사용자를 로그아웃시키는 강제 로테이션조차도 서비스 중단이 아닌 잠시의 불편함에 그치는 경우.
- 모든 검증자를 제어할 수 있어서, 변경 중에도 안정적인
kid나 JWKS 엔드포인트가 계속 작동해야 하는 외부 당사자가 없는 경우. - 만약 키를 교체해야 할 상황이 오더라도, 한 번의 계획된 유지보수 시간을 감수할 수 있는 경우.
소수의 사용자가 있는 내부 도구의 경우, 조용한 시간에 키를 교체하고 재로그인 비용을 감수하는 것이 키링을 구축하고 운영하는 것보다 훨씬 비용이 적게 듭니다. 중요한 것은 자신이 어떤 상황에 처해 있는지 아는 것입니다. 실제 사용자, 외부 검증자 또는 정해진 일정에 따라 로테이션해야 하는 규정 준수 요구사항이 생기는 순간, 다중 키 설계는 아무도 눈치채지 못하게 처음 로테이션을 수행할 때 그 가치를 증명합니다.
이 메커니즘은 하나의 규칙으로 요약됩니다. 가장 최신 키로 서명하고, 아직 유효한 토큰이 있을 수 있는 모든 키로 검증하며, 키의 가장 긴 토큰 수명이 만료된 후에만 해당 키를 폐기하십시오. 중첩 기간을 올바르게 설정하면 로테이션은 더 이상 사용자가 체감하는 이벤트가 아니게 됩니다. 사용자가 로그인 상태를 유지하는 동안 실행되는 작업이 됩니다.
관련 글
Redis 키스페이스 알림과 리스를 이용한 GPU 워커 풀 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스와 키스페이스 알림으로 구축된 바쁨을 인지하는 스케줄러가 있습니다.
MongoDB 변경 스트림을 이용한 Cron 스케줄 핫 스와핑
하드코딩된 cron은 스케줄이 변경될 때마다 재배포를 의미합니다. 데이터베이스에 스케줄을 저장하고 변경 스트림으로 데몬이 편집에 반응하도록 합니다. 여기에 그 설계와 실패 처리 방법이 있습니다.
단일 실행기 작업을 위한 CAS 및 하트비트를 사용하는 임대 잠금
일반적인 잠금은 홀더가 죽으면 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기서는 compare-and-swap 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드에 대해 설명합니다.
멱등성 업서트를 위한 결정론적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등성을 가져야 합니다. 콘텐츠에서 파생된 ID를 사용하면 이를 비용 없이 달성할 수 있습니다. 여기에 그 유도 과정과 안전성을 보장하는 한 가지 불변식이 있습니다.
Firestore의 MongoDB 호환성에서 `retryWrites=false` 함정
Firestore는 MongoDB 와이어 프로토콜을 지원하지만, 재시도 가능 쓰기는 거부합니다. 드라이버에서 이 기능이 기본적으로 활성화되어 있으면, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.
Rust에서 Go로 백엔드 이전, 그리고 빌드 시간이 결정적이었던 이유
Rust는 우리에게 거의 필요하지 않은 안전성을 주었고, 매일 싸워야 하는 컴파일 루프를 안겨주었습니다. 한 서비스를 Go로 전환하게 만든, 수치를 포함한 솔직한 트레이드오프가 여기에 있습니다.