- Published on
Go chi JWT 미들웨어 RS256 키 로테이션
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 JWT를 검증할 때 HS256(대칭키)로 시작했다가, 운영·보안 요구사항이 올라가면서 RS256(비대칭키)로 전환하는 경우가 많습니다. 특히 여러 서비스가 토큰을 검증하는 구조라면, 서명 키를 주기적으로 교체(키 로테이션)할 수 있어야 합니다. 그렇지 않으면 키 유출 사고나 만료 정책 변경 때 “전 서비스 동시 배포” 같은 고통이 발생합니다.
이 글에서는 Go chi 라우터에 붙일 JWT 검증 미들웨어를 직접 구현하고, RS256 키 로테이션을 kid(Key ID)와 JWKS(JSON Web Key Set)로 처리하는 실전 패턴을 정리합니다. 핵심은 아래 4가지입니다.
- 토큰 헤더의
kid로 올바른 공개키를 선택 - JWKS를 원격에서 가져오되, 캐시·만료·동시성 제어로 안정화
- 키 로테이션 시 “새 키 발급 + 구 키 그레이스 기간”을 동시에 운영
- 장애 시나리오(캐시 미스, JWKS 장애, 키 폐기)까지 고려
관련 운영 환경에서 자주 겪는 문제(예: 크래시 루프, 권한 문제, 디스크 폭증 등)는 별도 글에서 다뤘습니다. 예를 들어 배포 후 앱이 반복 재시작한다면 Kubernetes CrashLoopBackOff 원인 7가지와 재현·해결를 함께 참고하면 진단에 도움이 됩니다.
전제: RS256 + kid + JWKS가 왜 표준인가
RS256은 개인키로 서명하고 공개키로 검증합니다. 검증하는 서비스(리소스 서버)는 개인키를 몰라도 되므로 키 노출 면적이 줄어듭니다.
키 로테이션을 하려면 “어떤 공개키로 검증해야 하는지”를 토큰만 보고 알아야 합니다. 그래서 JWT 헤더에 kid를 넣고, 검증 서버는 kid에 해당하는 공개키를 JWKS에서 찾아 검증합니다.
- JWT 헤더:
alg=RS256,kid=... - JWKS: 여러 공개키를
keys배열로 제공 (현재 키 + 구 키)
아키텍처: 검증 미들웨어의 책임 분리
chi 미들웨어가 모든 걸 다 하면 테스트가 어려워집니다. 아래처럼 인터페이스를 나눠두면 운영 시나리오를 쉽게 커버할 수 있습니다.
KeyProvider:kid를 주면 공개키(또는 검증 키)를 반환JWTVerifier: 토큰 파싱/검증/클레임 추출AuthMiddleware: HTTP 요청에서 토큰을 뽑고 컨텍스트에 사용자 정보를 저장
라이브러리 선택
Go JWT 라이브러리는 github.com/golang-jwt/jwt/v5를 많이 씁니다. JWKS 파싱은 직접 해도 되지만, 운영에서는 검증 로직을 단순화하기 위해 github.com/lestrrat-go/jwx/v2/jwk 같은 라이브러리를 쓰는 편이 안전합니다.
아래 예제는 jwt/v5 + jwx/jwk 조합으로 작성합니다.
구현 1: JWKS 캐시(KeyProvider)
원격 JWKS 엔드포인트를 매 요청마다 호출하면 지연·장애에 취약해집니다. 따라서 메모리 캐시를 두고 TTL 기반으로 갱신합니다.
요구사항을 명확히 합시다.
- 기본은 캐시된 키로 검증
- 캐시에
kid가 없으면 즉시 JWKS를 강제 갱신 후 재시도 - JWKS 갱신은 동시 요청에서 1번만 일어나야 함(싱글플라이트)
- JWKS 서버 장애 시, “최근 캐시”로 일정 시간 버티는 전략(스테일 허용)이 유용
아래 코드는 단순하지만 운영에 필요한 뼈대를 갖춘 형태입니다.
package auth
import (
"context"
"crypto"
"errors"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
)
var (
ErrKeyNotFound = errors.New("key not found")
)
type JWKSKeyProvider struct {
URL string
HTTPClient *http.Client
CacheTTL time.Duration
StaleMaxAge time.Duration
mu sync.RWMutex
cachedSet jwk.Set
cachedAt time.Time
lastGoodAt time.Time
refreshMutex sync.Mutex
}
func (p *JWKSKeyProvider) getClient() *http.Client {
if p.HTTPClient != nil {
return p.HTTPClient
}
return &http.Client{Timeout: 3 * time.Second}
}
func (p *JWKSKeyProvider) isFresh(now time.Time) bool {
return p.cachedSet != nil && now.Sub(p.cachedAt) <= p.CacheTTL
}
func (p *JWKSKeyProvider) isStaleAllowed(now time.Time) bool {
if p.cachedSet == nil {
return false
}
// 마지막으로 성공적으로 가져온 시점 기준으로 스테일 허용
return now.Sub(p.lastGoodAt) <= p.StaleMaxAge
}
func (p *JWKSKeyProvider) refresh(ctx context.Context) error {
p.refreshMutex.Lock()
defer p.refreshMutex.Unlock()
now := time.Now()
if p.isFresh(now) {
return nil
}
set, err := jwk.Fetch(ctx, p.URL, jwk.WithHTTPClient(p.getClient()))
if err != nil {
// JWKS 장애 시 스테일 허용 정책
p.mu.RLock()
staleOK := p.isStaleAllowed(now)
p.mu.RUnlock()
if staleOK {
return nil
}
return err
}
p.mu.Lock()
p.cachedSet = set
p.cachedAt = now
p.lastGoodAt = now
p.mu.Unlock()
return nil
}
func (p *JWKSKeyProvider) PublicKey(ctx context.Context, kid string) (crypto.PublicKey, error) {
now := time.Now()
p.mu.RLock()
set := p.cachedSet
fresh := p.isFresh(now)
p.mu.RUnlock()
if !fresh {
_ = p.refresh(ctx)
p.mu.RLock()
set = p.cachedSet
p.mu.RUnlock()
}
if set != nil {
if key, ok := set.LookupKeyID(kid); ok {
var pubkey crypto.PublicKey
if err := key.Raw(&pubkey); err != nil {
return nil, err
}
return pubkey, nil
}
}
// 캐시에 없으면 강제 갱신 후 재시도 (로테이션 직후 대응)
if err := p.refresh(ctx); err != nil {
return nil, err
}
p.mu.RLock()
set = p.cachedSet
p.mu.RUnlock()
if set != nil {
if key, ok := set.LookupKeyID(kid); ok {
var pubkey crypto.PublicKey
if err := key.Raw(&pubkey); err != nil {
return nil, err
}
return pubkey, nil
}
}
return nil, ErrKeyNotFound
}
캐시 전략 팁
CacheTTL은 짧게(예: 5분),StaleMaxAge는 조금 길게(예: 1시간) 두면 JWKS 장애 시에도 인증이 즉시 전면 실패하지 않습니다.- 단, 스테일 허용은 “키 폐기/침해 대응”을 느리게 만들 수 있습니다. 보안 요구가 높다면
StaleMaxAge를 줄이거나 0으로 두고, 대신 JWKS 가용성을 강화하세요.
구현 2: JWT 검증기(Verifier)
검증기는 토큰 헤더에서 kid를 읽고, KeyProvider에서 공개키를 받아 서명을 검증합니다.
중요 포인트:
alg를 강제: 헤더의alg를 신뢰하지 말고 RS256만 허용iss,aud검증: 멀티 환경에서 토큰 오용을 막는 핵심exp,nbf,iat처리: 기본 검증 + 클락 스큐 허용
package auth
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Verifier struct {
Keys *JWKSKeyProvider
Issuer string
Audience string
ClockSkew time.Duration
}
type Claims struct {
jwt.RegisteredClaims
Sub string `json:"sub"`
Email string `json:"email"`
Role string `json:"role"`
}
func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, error) {
parser := jwt.NewParser(
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
jwt.WithAudience(v.Audience),
jwt.WithIssuer(v.Issuer),
jwt.WithLeeway(v.ClockSkew),
)
claims := &Claims{}
keyFunc := func(t *jwt.Token) (any, error) {
kid, _ := t.Header["kid"].(string)
kid = strings.TrimSpace(kid)
if kid == "" {
return nil, errors.New("missing kid")
}
pub, err := v.Keys.PublicKey(ctx, kid)
if err != nil {
return nil, fmt.Errorf("public key: %w", err)
}
return pub, nil
}
tok, err := parser.ParseWithClaims(tokenString, claims, keyFunc)
if err != nil {
return nil, err
}
if !tok.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
구현 3: chi JWT 미들웨어
이제 HTTP 레벨에서 Authorization: Bearer ...를 읽고 컨텍스트에 클레임을 넣습니다.
package auth
import (
"context"
"net/http"
"strings"
)
type ctxKey int
const claimsKey ctxKey = iota
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsKey).(*Claims)
return c, ok
}
func JWTMiddleware(v *Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
if authz == "" {
http.Error(w, "missing authorization", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authz, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
http.Error(w, "invalid authorization", http.StatusUnauthorized)
return
}
claims, err := v.Verify(r.Context(), parts[1])
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
chi 라우터에 적용하는 예시는 아래와 같습니다.
package main
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"example.com/myapp/auth"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
kp := &auth.JWKSKeyProvider{
URL: "https://issuer.example.com/.well-known/jwks.json",
CacheTTL: 5 * time.Minute,
StaleMaxAge: 30 * time.Minute,
}
verifier := &auth.Verifier{
Keys: kp,
Issuer: "https://issuer.example.com/",
Audience: "my-api",
ClockSkew: 30 * time.Second,
}
r.Group(func(pr chi.Router) {
pr.Use(auth.JWTMiddleware(verifier))
pr.Get("/me", func(w http.ResponseWriter, r *http.Request) {
c, _ := auth.ClaimsFromContext(r.Context())
w.Write([]byte("hello " + c.Sub))
})
})
http.ListenAndServe(":8080", r)
}
키 로테이션 설계: 발급자(issuer)와 검증자(resource)의 합의
키 로테이션이 실패하는 가장 흔한 이유는 “새 키로 서명하기 시작했는데, 검증 서비스가 그 키를 아직 모른다”입니다. 따라서 발급자(JWT를 생성하는 시스템)와 검증자(JWKS를 가져오는 시스템) 사이에 운영 규칙이 필요합니다.
권장 로테이션 절차
- 새 키 쌍 생성 (새
kid부여) - JWKS에 새 공개키를 먼저 게시 (기존 키도 유지)
- 충분한 전파 시간 대기 (캐시 TTL, CDN, 서비스 캐시 고려)
- 발급자가 새 개인키로 서명 시작 (
kid도 새 값) - 구 토큰의 최대 수명(
exp) + 여유 시간만큼 구 공개키를 JWKS에 유지 - 그 이후 구 공개키 제거
이때 “구 토큰의 최대 수명”은 액세스 토큰 TTL만이 아니라, 클라이언트가 실제로 들고 있는 토큰의 최대 잔존 시간을 의미합니다. 예를 들어 모바일 앱이 오프라인 상태로 오래 있다가 복귀하면 예외가 생길 수 있으니 정책을 분명히 하세요.
그레이스 기간을 계산하는 실전 공식
grace = access_token_ttl + clock_skew + cache_ttl + 배포지연
예: 액세스 토큰 15분, 클락 스큐 30초, 캐시 TTL 5분, 배포 지연 2분이면 최소 22~23분은 구 키를 유지하는 식입니다.
운영 이슈 1: kid 미존재 토큰 처리
일부 클라이언트/레거시가 kid 없이 토큰을 보내면, 서버는 어떤 키로 검증해야 할지 모릅니다.
대응 옵션:
- 정책적으로
kid필수로 강제(권장) - 과도기에는 “단일 활성 키”일 때만 기본 키를 사용(비권장, 로테이션을 어렵게 만듦)
실제로는 kid 없는 토큰을 허용하는 순간, 로테이션 때마다 예외 규칙이 늘어나고 사고 가능성이 커집니다.
운영 이슈 2: JWKS 장애와 인증 실패 폭발
JWKS 엔드포인트가 장애 나면, 키 캐시가 비어 있거나 만료된 서비스는 인증을 전부 실패시킬 수 있습니다. 위 StaleMaxAge 같은 스테일 허용은 이 문제를 완화합니다.
다만 스테일 허용을 쓴다면 관측 가능성(Observability)이 필수입니다.
- JWKS fetch 실패 횟수/지연
- 캐시 히트율
kid미스 비율(로테이션 직후 급증하면 정상일 수도, 공격일 수도)
만약 장애로 파드가 계속 죽는 상황이라면(예: 시작 시 JWKS를 강제 fetch하도록 만들어서 실패 시 panic) Kubernetes CrashLoopBackOff 원인 7가지와 재현·해결에서 제시하는 방식으로 재현 후 원인을 좁히는 게 빠릅니다.
운영 이슈 3: 동시성 버그와 갱신 폭주
트래픽이 높을 때 캐시 만료가 동시에 발생하면, 모든 요청이 JWKS를 당겨 “갱신 폭주”가 생깁니다. 위 예제는 refreshMutex로 갱신을 직렬화했지만, 더 고급으로는 singleflight를 써서 중복 호출을 합칠 수 있습니다.
또한 갱신 루틴에서 채널/고루틴을 섞어 쓰다 데드락이 나는 경우가 있습니다. 이런 이슈를 빠르게 디버깅하는 방법은 Go 채널 select 데드락 5분 디버깅 가이드가 도움 됩니다.
보안 체크리스트: RS256 검증에서 놓치기 쉬운 것
alg고정: 반드시 RS256만 허용 (WithValidMethods)iss,aud검증: 멀티 테넌트/멀티 환경에서 필수typ는 참고 정도:JWT여부를 보조로만 사용- 키 길이: RSA 2048 이상 권장(정책에 따라 3072)
- 토큰 TTL: 짧게(액세스 토큰), 리프레시 토큰은 별도 저장/폐기 전략
- 로그 주의: 토큰 원문을 로그에 남기지 말 것(PII/세션 탈취 위험)
테스트 전략: 로테이션을 자동화 테스트로 고정하기
로테이션은 “언젠가 반드시” 사고가 나는 지점이라 테스트로 고정해두는 게 좋습니다.
추천 테스트 케이스:
- JWKS에 키 A만 있을 때,
kid=A토큰 검증 성공 - JWKS에 키 A,B가 있을 때,
kid=B토큰 검증 성공 - 발급자가 B로 전환했는데 캐시에 A만 남은 상태에서, 강제 refresh 후 성공
- JWKS 장애 시 스테일 허용 기간에는 성공, 기간이 지나면 실패
마무리
Go chi 기반 API에서 RS256 JWT 검증과 키 로테이션을 안정적으로 운영하려면, 단순히 “JWT 라이브러리로 검증한다”를 넘어 kid 기반 키 선택, JWKS 캐시/동시성 제어, 스테일 허용 여부, 그리고 로테이션 절차(새 키 게시 후 서명 전환, 구 키 그레이스 유지)를 하나의 설계로 묶어야 합니다.
위 예제 코드를 바탕으로 다음을 추가하면 더 운영 친화적인 미들웨어가 됩니다.
- 메트릭(Prometheus)과 구조화 로그로 JWKS 상태 관측
kid미스가 급증할 때 레이트 리밋 또는 차단 정책- 멀티 issuer 지원(여러 JWKS URL) 시 issuer별 캐시 분리
키 로테이션은 “한 번 구현하고 끝”이 아니라, 실제로 교체 리허설을 해봐야 합니다. 운영 환경에서 로테이션 드릴(예: 분기 1회)을 돌려두면, 사고를 이벤트가 아니라 절차로 바꿀 수 있습니다.