이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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의 role을 ADMIN으로 바꾸고, 서버가 서명 검증 없이 payload를 읽는지 확인한다.
JWT 검증 누락은 “로그인 안 하고 접근”보다 더 위험할 수 있다. token 모양이 그럴듯하기 때문에 로그와 모니터링에서 정상 인증처럼 보일 수 있다.
취약한 요청/응답 예시
GET /admin/users/1004 HTTP/1.1
Host: admin.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsImF1ZCI6ImJpbGxpbmciLCJyb2xlIjoiQURNSU4ifQ.invalidHTTP/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=ab913f20level=WARN event=jwt_rejected result=blocked reason=expired issuer=https://idp.example.com subject_hash=81fa exp=2026-06-30T09:00:00Z trace_id=ab913f21JWT 원문을 로그에 남기지 않는다. 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이라 로그 유출 시 곧바로 재사용될 수 있기 때문이다.