Published on

Spring Security JWT 401, JWK 로테이션·kid 불일치 해결

Authors

서버는 멀쩡해 보이는데 특정 시점부터 갑자기 401 Unauthorized가 폭증하는 경우가 있습니다. 특히 Spring Security OAuth2 Resource Server로 JWT를 검증할 때, 원인이 JWK 로테이션(키 교체)과 kid 불일치로 귀결되는 사례가 매우 흔합니다.

이 글은 다음을 목표로 합니다.

  • kid가 무엇이고 왜 로테이션에서 문제가 터지는지
  • Spring Security가 JWK를 어떻게 가져오고 캐시하는지
  • 운영에서 401 폭탄을 막는 설정과 배포 전략
  • 로그와 메트릭으로 "정말 kid/JWK 문제"인지 빠르게 확증하는 방법

401의 정체: 인증 실패가 아니라 "서명 검증 실패"일 때

리소스 서버 관점에서 401은 단순히 토큰이 없어서가 아니라, 토큰은 왔는데 검증에 실패해서 발생하는 경우가 많습니다. 특히 아래 케이스가 대표적입니다.

  • 토큰의 kid 헤더에 해당하는 공개키를 JWK Set에서 찾지 못함
  • JWK Set은 받아왔지만 키가 바뀌었고, 캐시가 갱신되지 않아 예전 키로 검증 시도
  • IdP가 새 키로 서명하기 시작했는데, JWK 엔드포인트가 아직 새 키를 노출하지 않음(전파 지연)
  • 여러 IdP 인스턴스에서 키셋이 일관되지 않음(롤링 중 불일치)

이때 Spring Security 내부에서는 대개 JwtException 계열 예외가 발생하고 최종적으로 401로 내려갑니다.

JWT 헤더의 kid: "이 토큰은 이 키로 검증해"라는 힌트

JWT는 보통 header.payload.signature 구조이며, 헤더에는 다음 정보가 들어갑니다.

  • alg: 서명 알고리즘(예: RS256)
  • kid: Key ID, 즉 "어떤 키"로 서명했는지 식별자

리소스 서버는 kid를 보고 JWK Set에서 동일한 kid를 가진 공개키를 찾아 서명을 검증합니다.

문제는 로테이션 시점입니다.

  • IdP가 새 개인키로 서명 시작
  • 그런데 리소스 서버가 보는 JWK Set에는 아직 새 공개키가 없거나
  • 리소스 서버가 예전 JWK Set을 캐시하고 있어 새 키를 못 봄

결과는 kid 불일치, 그리고 401입니다.

Spring Security는 JWK를 어떻게 가져오고 캐시할까

Spring Boot에서 아래처럼 설정하면 리소스 서버는 issuer-uri 또는 jwk-set-uri를 기반으로 JWK를 가져옵니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com/realms/demo
          # 또는 jwk-set-uri: https://idp.example.com/realms/demo/protocol/openid-connect/certs

내부적으로는 Nimbus 기반 구현이 동작하며, 핵심 포인트는 다음입니다.

  • JWK Set은 네트워크로 fetch 된다
  • fetch 결과는 일정 시간 캐시된다
  • 검증 시 kid에 맞는 키를 찾는다
  • 못 찾으면(또는 서명 검증 실패) 예외가 나고 401

즉 운영에서 중요한 건 "키가 바뀌는 속도"와 "캐시가 갱신되는 속도"의 경쟁입니다.

증상 체크리스트: kid/JWK 이슈인지 빠르게 확증하기

1) 실패 토큰의 kid를 확인한다

문제가 되는 토큰을 하나 확보해 헤더를 디코딩합니다.

TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDItMjUta2V5LTAxIn0.eyJzdWIiOiIxMjMifQ.XYZ"

# 헤더만 확인 (JWT 첫 번째 조각)
echo "$TOKEN" | awk -F. '{print $1}' | base64 -d 2>/dev/null

여기서 kid 값을 얻습니다.

2) JWK Set에 해당 kid가 있는지 확인한다

JWK_URL="https://idp.example.com/realms/demo/protocol/openid-connect/certs"
curl -s "$JWK_URL" | jq '.keys[].kid'
  • 토큰 kid가 목록에 없다면 거의 확정입니다.
  • 목록에 있는데도 실패한다면, alg 불일치, 키 타입 불일치, 혹은 서명 알고리즘 정책 문제를 의심합니다.

3) Spring Security 로그로 "어디서" 실패했는지 본다

운영에서 디버그 로그를 무작정 올리기 어렵다면, 최소한 아래 패키지의 로그 레벨을 일시적으로 조정해 원인을 좁힙니다.

logging:
  level:
    org.springframework.security: INFO
    org.springframework.security.oauth2: DEBUG
    com.nimbusds.jose: DEBUG

로그에서 다음 뉘앙스를 찾습니다.

  • "키를 찾지 못했다" 류 메시지
  • JWK fetch 시도와 실패(타임아웃, 503, TLS 오류)
  • issuer 검증 실패(issuer-uri 설정 오류)

가장 흔한 원인 4가지와 해결 전략

1) IdP는 새 키로 서명했는데 JWK 엔드포인트는 아직 갱신 전

이건 IdP 운영/배포 방식 문제입니다.

  • 서명 키를 교체할 때는 먼저 JWK Set에 새 공개키를 추가하고
  • 충분한 전파 시간을 둔 뒤
  • 그 다음에 새 개인키로 서명하도록 전환해야 합니다.

즉 "JWK 공개키 선공개"가 필요합니다.

해결: overlap 기간을 둔 로테이션

  • JWK Set에는 최소 2개의 키(구키, 신규키)를 공존
  • 토큰 발급은 신규키로 전환
  • 구키로 발급된 토큰의 exp가 모두 만료될 때까지 구키를 유지

이 overlap이 없으면 리소스 서버는 어떤 최적화도 못 합니다.

2) 리소스 서버의 JWK 캐시 갱신이 늦다

로테이션 직후 특정 시간 동안만 401이 터지고, 시간이 지나면 자연스럽게 회복되는 패턴이라면 캐시 갱신 이슈일 확률이 높습니다.

해결: JWK fetch 타임아웃과 캐시 정책 점검

Spring Security가 사용하는 Nimbus 쪽은 캐시와 리트라이 정책이 간접적으로 영향을 받습니다. 실무에서는 다음을 권장합니다.

  • JWK 엔드포인트에 적절한 Cache-Control 헤더를 설정(IdP 측)
  • 리소스 서버에서 JWK fetch가 실패했을 때 과도하게 오래된 캐시를 붙잡지 않도록 관측
  • 네트워크 타임아웃을 합리적으로 설정(너무 길면 스레드가 묶이고, 너무 짧으면 일시 장애에 취약)

아래는 NimbusJwtDecoder를 직접 구성하면서 HTTP 타임아웃을 명시하는 예시입니다.

import com.nimbusds.jose.util.DefaultResourceRetriever;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Configuration
public class JwtDecoderConfig {

  @Bean
  JwtDecoder jwtDecoder() {
    String jwkSetUri = "https://idp.example.com/realms/demo/protocol/openid-connect/certs";

    // connectTimeout, readTimeout (ms)
    DefaultResourceRetriever retriever = new DefaultResourceRetriever(2000, 2000);

    return NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
        .resourceRetriever(retriever)
        .build();
  }
}

포인트는 "JWK fetch가 느려서" 인증 스레드가 잠기거나, "JWK fetch 실패가 연쇄"로 이어져 401이 폭발하는 상황을 줄이는 것입니다.

3) 여러 IdP 인스턴스 간 키셋 불일치(롤링 배포/클러스터 동기화 문제)

IdP를 다중 인스턴스로 운영할 때 다음이 치명적입니다.

  • A 인스턴스는 새 키로 서명
  • B 인스턴스의 JWK Set은 아직 구키만 노출
  • 리소스 서버는 B에서 JWK를 받아오거나, 캐시가 B의 응답으로 갱신됨

이 경우 로테이션 직후가 아니라도 간헐적 401이 계속 발생할 수 있습니다.

해결: JWK 엔드포인트의 일관성 보장

  • JWK Set은 인스턴스 로컬이 아니라 공유 스토리지/일관된 설정에서 제공
  • 로드밸런서 뒤에서 JWK 응답이 인스턴스마다 달라지지 않게 보장
  • 가능하면 JWK는 CDN 또는 단일 엔드포인트로 제공

운영 관점에서 "리소스 서버 문제"로 보이지만 사실상 IdP의 배포/동기화 문제인 경우가 많습니다.

4) kid는 맞는데도 실패: alg 정책, issuer/audience 검증 문제

kid가 존재함에도 401이 나면 다음도 확인해야 합니다.

  • 토큰 alg가 리소스 서버가 허용하는 알고리즘과 일치하는지
  • issuer-uri가 실제 토큰의 iss와 일치하는지
  • aud 검증을 커스텀으로 넣어뒀다면 대상 서비스가 맞는지

예를 들어 audience를 강제하면, 다른 클라이언트용 토큰이 들어올 때도 401이 납니다.

import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;

import java.util.List;

public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
  private final String audience;

  public AudienceValidator(String audience) {
    this.audience = audience;
  }

  @Override
  public OAuth2TokenValidatorResult validate(Jwt token) {
    List<String> aud = token.getAudience();
    if (aud != null && aud.contains(audience)) {
      return OAuth2TokenValidatorResult.success();
    }
    return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
  }
}

이 검증을 추가했다면, 로테이션과 무관하게 특정 트래픽만 401이 날 수 있으니 분리해서 봐야 합니다.

운영에서 재발 방지: 로테이션 설계, 캐시, 관측성

1) 로테이션을 "키 2개 공존"으로 설계한다

가장 안전한 절차는 다음입니다.

  1. JWK Set에 신규 공개키 추가(구키 유지)
  2. 충분한 전파 시간 대기(리소스 서버 캐시 만료, CDN 캐시 반영)
  3. 토큰 서명 키를 신규키로 전환
  4. 구키로 발급된 토큰이 만료될 때까지 구키 유지
  5. 구키 제거

여기서 2번 전파 시간이 없으면, 리소스 서버가 아무리 빨라도 kid 불일치가 발생합니다.

2) JWK 엔드포인트 가용성을 SLO로 관리한다

JWK 엔드포인트가 느리거나 불안정하면, 인증은 곧바로 장애로 이어집니다.

  • JWK 응답 시간, 5xx 비율 모니터링
  • 리소스 서버에서 401 비율이 아니라 "JWT 검증 실패" 원인별 카운팅

Spring Security는 기본적으로 401로 뭉개져 보이기 쉬우니, 인증 필터 단계에서 예외를 관측 가능하게 남기는 것이 중요합니다.

3) 장애가 다른 레이어로 번지지 않게 한다

JWK fetch 지연은 인증 요청 스레드를 잠글 수 있고, 결국 커넥션 풀 고갈 같은 2차 장애로 번질 수 있습니다. 인증 장애와 성능 장애가 겹치면 원인 파악이 더 어려워집니다.

  • 인증 단계에서 외부 네트워크 호출이 병목이 되지 않도록 타임아웃을 짧게
  • 리소스 서버 인스턴스 수, 스레드 풀, 커넥션 풀을 함께 점검

커넥션 풀 고갈이 동반된다면 이 글도 같이 보면 진단에 도움이 됩니다.

4) 배포 후 "캐시 꼬임" 패턴을 의심하라

로테이션 직후에만 간헐적으로 실패하고, 재시작하면 나아지는 듯 보이면 캐시/전파 문제일 가능성이 큽니다. 애플리케이션 캐시뿐 아니라 CDN, 프록시, 로드밸런서 캐시가 얽히기도 합니다.

배포/캐시가 얽혀 장애가 재발하는 패턴은 다른 영역에서도 자주 나옵니다. 캐시 진단 관점은 아래 글의 접근법을 참고할 만합니다.

실전 트러블슈팅 플로우(30분 안에 결론 내기)

  1. 401 샘플 요청에서 JWT를 확보한다(개인정보 마스킹 필수)
  2. JWT 헤더에서 kid, alg를 확인한다
  3. JWK Set에서 해당 kid 존재 여부를 확인한다
  4. 존재하지 않으면 IdP 로테이션 overlap/전파 지연 문제로 분류한다
  5. 존재하면 issuer/audience/alg, 그리고 JWK fetch 실패 로그를 확인한다
  6. IdP 다중 인스턴스면 JWK 응답이 인스턴스별로 동일한지 확인한다
  7. 임시 완화책: overlap 기간 확대, JWK 엔드포인트 안정화, 리소스 서버 타임아웃 조정

결론

Spring Security에서 JWT 401이 갑자기 늘어날 때, 애플리케이션 코드보다 먼저 kid와 JWK 로테이션 절차를 의심하는 게 정답인 경우가 많습니다. 핵심은 다음 두 줄입니다.

  • 토큰은 새 키로 서명되는데, 리소스 서버가 새 공개키를 못 보면 무조건 실패한다
  • 해결은 리소스 서버 튜닝만으로는 부족하고, IdP의 로테이션을 "공존(overlap)" 기반으로 설계해야 한다

위의 확인 절차로 kid 불일치인지 빠르게 확증하고, overlap 로테이션과 JWK 엔드포인트 일관성/가용성까지 묶어서 재발을 막는 쪽으로 접근하면 운영에서 401 폭탄을 크게 줄일 수 있습니다.