이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • JWT의 header, payload, signature는 각각 무엇이며 무엇을 보장하지 않는가?
  • JWT 검증에서 서명 외에 iss, aud, exp, alg, kid를 확인해야 하는 이유는 무엇인가?
  • Spring Resource Server에서 JWT claim 검증을 어디에 추가해야 하는가?

개요

JWT는 JSON Web Token이다. 보통 header.payload.signature 세 부분을 Base64URL로 인코딩해 표현한다.

JWT는 암호화가 아니라 서명된 claim 표현이다. JWE를 쓰지 않는 일반 JWT의 header와 payload는 누구나 디코딩해서 읽을 수 있다.

따라서 JWT payload에는 비밀번호, 주민번호, refresh token, 내부 secret, 긴 수명의 민감 권한 정보를 넣지 않는다.

공격 시나리오

공격자는 다른 서비스용 JWT를 가지고 있다.

예를 들어 billing.example.com용 access token을 admin.example.com API에 보낸다. 두 서비스가 같은 issuer와 같은 key를 쓰는데 admin API가 audience를 검증하지 않으면 token이 받아들여질 수 있다.

또는 공격자는 JWT payload의 roleADMIN으로 바꾸고, 서버가 서명 검증 없이 payload를 읽는지 확인한다.

JWT 검증 누락은 “로그인 안 하고 접근”보다 더 위험할 수 있다. token 모양이 그럴듯하기 때문에 로그와 모니터링에서 정상 인증처럼 보일 수 있다.

취약한 요청/응답 예시

GET /admin/users/1004 HTTP/1.1
Host: admin.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsImF1ZCI6ImJpbGxpbmciLCJyb2xlIjoiQURNSU4ifQ.invalid
HTTP/1.1 200 OK
Content-Type: application/json
 
{"userId":1004,"email":"user@example.com","role":"ADMIN"}

서버가 JWT를 디코딩만 하고 서명, issuer, audience, 만료를 검증하지 않으면 token은 인증 증거가 아니라 공격자 입력이 된다.

주제별 핵심 판단

항목확인 이유
alg허용된 알고리즘인지 확인한다
kid신뢰하는 key set의 key인지 확인한다
iss신뢰하는 발급자인지 확인한다
aud이 API를 위한 token인지 확인한다
exp만료된 token을 거부한다
nbf아직 유효하지 않은 token을 거부한다
sub내부 subject와 매핑 가능한지 확인한다
jti추적과 폐기, 재사용 탐지에 활용한다

서명만 맞아도 audience가 다르면 이 API가 받아서는 안 된다.

개선된 요청/응답 예시

JWT 검증은 다음을 포함한다.

  • 개선된 검증은 signature뿐 아니라 issuer, audience, exp, nbf, alg, key id를 함께 확인한다.
  • 실패 응답은 token 구조와 검증 실패 세부 사유를 외부에 노출하지 않는다.

방어 설계

  • 허용 알고리즘을 서버 설정으로 고정한다.
  • issuer를 정확히 비교한다.
  • audience를 API별로 검증한다.
  • 만료와 not-before를 검증하고 clock skew를 제한한다.
  • JWK Set URI는 신뢰한 issuer에서만 가져온다.
  • kid로 파일 경로나 임의 URL을 조합하지 않는다.
  • 권한 claim은 최소화하고 고위험 권한은 서버 조회나 짧은 TTL로 보완한다.

JWT BCP는 알고리즘 혼동과 cross-JWT confusion을 특히 조심하라고 본다. access token, ID token, refresh token을 같은 검증 로직으로 뭉개면 안 된다.

Spring/HTTP 예시

@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation("https://idp.example.com");
 
    OAuth2TokenValidator<Jwt> issuer =
        JwtValidators.createDefaultWithIssuer("https://idp.example.com");
    OAuth2TokenValidator<Jwt> audience = token -> {
        if (token.getAudience().contains("orders-api")) {
            return OAuth2TokenValidatorResult.success();
        }
        OAuth2Error error = new OAuth2Error("invalid_token", "wrong audience", null);
        return OAuth2TokenValidatorResult.failure(error);
    };
 
    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuer, audience));
    return decoder;
}
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/admin/**").hasAuthority("SCOPE_admin")
            .anyRequest().authenticated())
        .build();
}

Filter에서 JWT가 검증되어도 Service의 resource ownership 검사는 별도로 필요하다.

운영 로그와 감사 지점

level=WARN event=jwt_rejected result=blocked reason=wrong_audience issuer=https://idp.example.com audience=billing expected=orders-api kid=k-2026-01 trace_id=ab913f20
level=WARN event=jwt_rejected result=blocked reason=expired issuer=https://idp.example.com subject_hash=81fa exp=2026-06-30T09:00:00Z trace_id=ab913f21

JWT 원문을 로그에 남기지 않는다. jti, subject hash, issuer, audience, kid, rejection reason 정도로 충분히 추적 가능하게 만든다.

실전 판단 기준

  • 서명을 제거하거나 payload를 바꾸면 401인가?
  • JWT payload는 읽을 수 있는 값이며 암호화된 비밀 저장소가 아니다.
  • key rotation과 폐기 전략이 없으면 짧은 TTL만으로 사고 대응이 어렵다.

테스트 포인트

  • aud가 다른 token이 거부되는가?
  • 만료된 token과 nbf가 미래인 token이 거부되는가?
  • 허용하지 않은 alg가 거부되는가?
  • ID token을 access token처럼 보냈을 때 거부되는가?
  • JWT payload에 민감 개인정보나 refresh token이 들어가지 않는가?

위험 신호!

  • JWT를 Base64.decode해서 claim만 읽고 인증 처리한다.
  • 서명은 검증하지만 audience를 보지 않는다.
  • 여러 API가 같은 token을 audience 없이 공유한다.
  • role=ADMIN 같은 긴 수명의 권한 claim을 과하게 신뢰한다.
  • token 검증 실패 로그에 JWT 원문이 남는다.
  • kid 처리에서 신뢰하지 않은 경로나 URL을 사용한다.

확인 질문

확인 질문

  • JWT payload가 암호화가 아닌 이유는 무엇인가?
    • 일반 JWT는 header와 payload를 Base64URL로 인코딩할 뿐이라 누구나 읽을 수 있기 때문이다.
  • audience 검증이 필요한 이유는 무엇인가?
    • 다른 API나 클라이언트를 위해 발급된 token이 이 API에서 재사용되는 것을 막기 위해서다.
  • JWT 검증 실패 로그에 원문 token을 남기면 안 되는 이유는 무엇인가?
    • token 자체가 bearer credential이라 로그 유출 시 곧바로 재사용될 수 있기 때문이다.

참고 문서