Published on

Spring Security OAuth2 JWT 서명키 불일치 401 해결

Authors

서버 간 OAuth2 인증을 붙이고 나서 가장 당황스러운 장애 중 하나가 **"분명 토큰은 발급되는데 API 호출은 401"**입니다. 로그를 보면 대개 Invalid signature, JwtValidationException, Signed JWT rejected 같은 메시지가 찍히고, 원인은 한 가지로 수렴합니다. 리소스 서버(Resource Server)가 JWT를 검증할 때 사용하는 서명키가, 인증 서버(Authorization Server)가 서명에 사용한 키와 다르다는 것입니다.

이 글에서는 Spring Security OAuth2(Resource Server) 기준으로 JWT 서명키 불일치로 인한 401을 빠르게 재현/진단/해결하는 체크리스트와 실전 설정 예제를 제공합니다. (리다이렉트 루프 문제는 별도 이슈이므로 필요하면 Spring Security OAuth2 리다이렉트 루프 끊는 법도 같이 참고하세요.)

증상 패턴: 401인데 토큰은 정상처럼 보일 때

다음 중 하나라도 해당하면 “서명키/검증키 불일치” 가능성이 큽니다.

  • Authorization Server에서 받은 JWT를 jwt.io에 붙여 넣으면 payload는 정상적으로 보인다(하지만 signature verify는 실패).
  • 리소스 서버 로그에 아래와 유사한 메시지
    • org.springframework.security.oauth2.jwt.JwtValidationException: An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature
    • Failed to validate JWT / Invalid JWS signature
  • 특정 인스턴스/특정 시간대에만 401이 난다(키 회전/캐시/멀티 인스턴스 불일치 가능성)

JWT 서명/검증 구조를 30초만에 복기

  • Authorization Server: 개인키(private key) 로 JWT에 서명 (RS256/ES256 등 비대칭) 또는 공유 비밀키(secret) 로 서명 (HS256 등 대칭)
  • Resource Server: 공개키(public key) 또는 동일 secret 으로 서명 검증

즉, 401의 핵심은 아래 중 하나입니다.

  1. 알고리즘 불일치(HS256 vs RS256)
  2. 키 자체 불일치(다른 키 파일/다른 secret)
  3. kid가 가리키는 키가 다름(JWK Set 구성/키 회전)
  4. issuer/audience 검증에서 탈락(서명은 맞아도 401)
  5. JWK 캐시/네트워크 문제로 최신 키를 못 받아옴

1단계: 토큰 헤더에서 alg/kid 확인

먼저 토큰 헤더를 확인합니다.

TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6IjE2Y..."

# header만 base64url 디코딩(맥/리눅스)
echo "$TOKEN" | cut -d '.' -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq

확인 포인트:

  • alg: RS256/ES256/HS256
  • kid: 키 식별자. JWK Set에서 어떤 키를 선택해야 하는지 결정

alg=HS256인데 리소스 서버가 JWK(공개키)로 검증하려 하면 당연히 실패합니다. 반대로 RS256인데 리소스 서버가 secret으로 검증해도 실패합니다.

2단계: Spring Resource Server 설정이 “어떤 방식”인지 확인

Spring Security에서 JWT 검증 설정은 크게 두 가지입니다.

(A) issuer-uri 기반(OIDC/JWK 자동 발견)

가장 권장되는 방식입니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com

Spring은 /.well-known/openid-configuration을 통해 jwks_uri를 찾고, 거기서 키를 받아 검증합니다.

(B) jwk-set-uri 직접 지정

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://auth.example.com/oauth2/jwks

(C) 공개키/시크릿을 직접 박아 검증(운영에서는 신중)

  • 공개키로 검증(비대칭)
  • 시크릿으로 검증(대칭)
@Bean
JwtDecoder jwtDecoder() {
    // 예: NimbusJwtDecoder.withPublicKey(rsaPublicKey).build();
    // 또는 NimbusJwtDecoder.withSecretKey(secretKey).build();
}

운영에서 (C)는 키 회전/다중 인스턴스/환경 분리에서 사고가 나기 쉬워, 가능하면 (A) 또는 (B)를 추천합니다.

3단계: 실제 원인 TOP 7과 해결

원인 1) Authorization Server와 Resource Server의 알고리즘(alg) 불일치

  • Auth 서버는 HS256으로 서명
  • Resource 서버는 RS256 공개키 검증을 기대

해결:

  • Auth 서버에서 RS256로 통일하거나(권장)
  • Resource 서버도 HS256 secret 기반으로 검증하도록 맞춥니다.

Spring Resource Server에서 HS256 secret을 쓰는 예:

import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Bean
JwtDecoder jwtDecoder() {
    byte[] secret = System.getenv("JWT_SECRET").getBytes();
    var key = new SecretKeySpec(secret, "HmacSHA256");
    return NimbusJwtDecoder.withSecretKey(key).build();
}

주의: HS256은 공유 비밀키가 여러 서비스에 퍼지기 쉬워 운영 보안 관점에서 리스크가 큽니다.

원인 2) 같은 RS256인데 “다른 키”로 서명/검증

가장 흔합니다.

  • 로컬에서는 A키로 서명
  • 운영 리소스 서버는 B키로 검증
  • 혹은 K8s Secret이 환경마다 다르게 배포됨

해결:

  • Auth 서버의 JWK Set에서 노출되는 공개키가 무엇인지 확인하고, 리소스 서버는 그 JWK를 사용하게 합니다.

JWK Set 확인:

curl -s https://auth.example.com/oauth2/jwks | jq

리소스 서버는 issuer-uri 또는 jwk-set-uri로 연결되도록 구성해 “키 파일 복사”를 없애는 게 가장 안전합니다.

원인 3) kid 불일치 또는 JWK Set에 kid가 없음

토큰 헤더에 kid=abc가 있는데, JWK Set에는 kid=def만 있다면 검증할 키를 선택하지 못합니다.

해결:

  • Auth 서버가 키를 회전했다면, 새 키가 JWK Set에 반영되었는지 확인
  • 리소스 서버가 JWK를 캐싱 중이라면 캐시 갱신이 필요(아래 원인 5 참고)

원인 4) issuer(iss) 검증 실패(서명은 맞아도 401)

issuer-uri를 쓰면 Spring은 기본적으로 iss를 엄격히 검증합니다.

  • 토큰의 iss: https://auth.example.com/
  • 리소스 서버 설정: https://auth.example.com (슬래시 유무 차이)

해결:

  • issuer-uri를 토큰의 iss완전히 동일하게 맞추세요.

토큰 payload 확인:

echo "$TOKEN" | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '.iss, .aud'

원인 5) JWK 캐시/키 회전 타이밍 문제(간헐적 401)

키 회전(rotate) 직후에 일부 인스턴스만 401이 나거나, 일정 시간 후 정상화되는 케이스입니다.

  • 리소스 서버가 이전 키를 캐시
  • Auth 서버는 새 키로 서명

해결 방향:

  • JWK Set에는 이전 키와 새 키를 일정 기간 함께 제공(grace period)
  • 리소스 서버의 JWK 캐시 전략을 점검

Spring이 내부적으로 Nimbus를 쓰는 경우, JWK 캐시는 기본 동작이 있지만 운영 요구사항(회전 주기/장애 허용)에 따라 커스터마이징이 필요할 수 있습니다.

예: NimbusJwtDecoderRestOperations/타임아웃을 주고, 네트워크 실패 시 재시도 전략을 별도로 둡니다.

@Bean
JwtDecoder jwtDecoder() {
    var decoder = NimbusJwtDecoder
        .withJwkSetUri("https://auth.example.com/oauth2/jwks")
        .build();
    return decoder;
}

키 회전 시점에 401이 폭발한다면, “리다이렉트 문제”가 아니라 “키/캐시 문제”일 가능성이 높습니다.

원인 6) 잘못된 jwk-set-uri(환경별 도메인/프록시)

운영에서 흔한 실수:

  • 내부망에서는 http://auth:9000/jwks
  • 외부망에서는 https://auth.example.com/jwks
  • 리소스 서버가 접근 불가한 주소를 보고 JWK를 못 받아와서 검증 실패

해결:

  • 리소스 서버가 실제로 접근 가능한 jwk-set-uri인지 확인
  • ALB/Ingress 경유 시 경로가 바뀌는지 확인

테스트:

# 리소스 서버 파드/컨테이너 내부에서 실행하는 게 중요
curl -v https://auth.example.com/oauth2/jwks

원인 7) audience(aud) 검증 실패(서명 OK인데 401)

서명키 불일치로 오해하기 쉬운 케이스입니다. 토큰 검증 로직에 aud 검증을 추가해두면, 대상 API가 아니면 401이 납니다.

해결:

  • Auth 서버가 발급하는 aud를 API 리소스 식별자로 통일
  • Resource Server에서 aud 검증을 명시적으로 구현

예시(간단한 audience validator):

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 org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;

@Bean
JwtDecoder jwtDecoder() {
    String issuer = "https://auth.example.com";
    JwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);

    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
    OAuth2TokenValidator<Jwt> withAudience = jwt -> {
        var aud = jwt.getAudience();
        return aud.contains("api://order-service")
            ? OAuth2TokenValidatorResult.success()
            : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
    };

    ((org.springframework.security.oauth2.jwt.NimbusJwtDecoder) decoder)
        .setJwtValidator(new org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));

    return decoder;
}

운영에서 가장 안전한 권장 구성(정답에 가까운 템플릿)

  • Auth 서버: OIDC Discovery + JWK Set 제공
  • Resource 서버: issuer-uri로 설정
  • 키 회전: JWK Set에 old/new 키를 일정 기간 동시 제공

Resource Server 설정 예:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com

그리고 애플리케이션 로그 레벨을 잠깐 올려 원인을 바로 확인합니다.

logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: DEBUG

트러블슈팅 체크리스트(현장용)

  1. 토큰 헤더의 alg, kid 확인
  2. 리소스 서버가 issuer-uri/jwk-set-uri 중 무엇을 쓰는지 확인
  3. curl https://.../jwks리소스 서버 실행 환경에서 성공하는지 확인
  4. ississuer-uri와 완전히 같은지 확인(슬래시 포함)
  5. 키 회전 직후 간헐적이면 JWK 캐시/동시 제공(이전 키 유지) 점검
  6. 서명은 맞는데도 401이면 aud, exp, nbf, clock skew 검증을 의심

부록: 401을 302 리다이렉트로 착각하는 경우

브라우저 기반 OAuth2 로그인 흐름에서는 401이 곧바로 리다이렉트/루프로 보이기도 합니다. 이때는 JWT 서명 문제와 별개로 redirect_uri 설정, 세션/쿠키, 프록시 헤더 이슈가 섞여 있을 수 있습니다. 증상이 “로그인 페이지로 계속 튕김”이라면 아래 글도 같이 보면 디버깅 시간이 줄어듭니다.

마무리

JWT 서명키 불일치 401은 원인이 단순하지만, 멀티 환경/키 회전/프록시가 끼면 “가끔만 실패”하는 형태로 나타나 진단이 어려워집니다. 해결의 핵심은 리소스 서버가 키를 직접 들고 있지 않게 만들고(파일/시크릿 복사 제거), issuer-uri 기반으로 JWK를 자동 추적하게 구성하는 것입니다.

지금 401이 난다면, 우선 토큰의 alg/kid/iss를 확인하고, 리소스 서버에서 JWK 엔드포인트 접근을 curl로 검증한 뒤, 키 회전과 캐시 정책까지 점검해 보세요. 이 순서대로 하면 대부분 10~20분 안에 원인을 특정할 수 있습니다.