설명
JWT의 내용을 파싱하는 것과 신뢰할 수 있는 서명을 검증하는 것은 다릅니다. 인증에 사용하는 토큰을 서명 검증 없이 수용하면 공격자가 사용자나 권한 클레임을 위조할 수 있습니다. 검증 키를 설정했더라도 서명 없는 토큰을 허용하는 파싱 경로가 있으면 충분하지 않습니다.
JJWT의 동작은 버전에 따라 다릅니다. 아래 JJWT 0.11.x 예시의 일반 parse()는 서명 없는 JWT도 처리할 수 있습니다. 0.12 이상에서는 서명 없는 JWT가 기본적으로 거부되므로, 모든 일반 파싱 호출이 서명 검증을 생략한다고 단정하면 안 됩니다.
잠재적 영향
- 위조한 사용자·권한 클레임을 통한 계정 사칭이나 권한 상승
- 신뢰하지 않는 발급자나 다른 서비스용 토큰의 오용
- 토큰을 믿고 수행하는 데이터 조회·변경의 무단 실행
해결 방법
- 서명된 클레임을 요구하는 API를 사용하세요. JJWT 0.12 이상에서는
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)을 사용하고, 서명 없는 토큰을 허용하지 마세요. 기존 0.11.x의 대응 API는parseClaimsJws()입니다. - 검증 키와 허용 알고리즘은 신뢰된 설정에서 선택하세요. 키를 교체하는 환경에서는 검토된 키 목록이나
keyLocator를 사용하고, 토큰이 지정한 임의 URL에서 키를 가져오지 마세요. - 만료 시간, 발급자, 대상 서비스와 필요한 클레임을 확인하세요. 서명 검증만으로 모든 권한 요구가 충족되지는 않습니다.
JwtException등 검증 실패를 인증 거부로 처리하고, 실패한 토큰을 단순 디코딩하여 다시 수용하지 마세요.
예시
다음은 JJWT 0.11.x의 두 파싱 경로를 비교하는 Spring 발췌입니다. HMAC 알고리즘에 맞는 충분한 길이의 SecretKey를 신뢰된 구성에서 주입한다고 가정합니다. 실제 애플리케이션에서는 클레임 정책과 인증 오류 응답도 구성하세요.
변경 전
java
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import org.springframework.web.bind.annotation.*;
@RestController
class TokenController {
private final SecretKey key;
TokenController(SecretKey key) {
this.key = key;
}
@GetMapping("/me")
public String me(@RequestHeader("Authorization") String authz) {
if (!authz.startsWith("Bearer ")) {
throw new JwtException("missing bearer token");
}
String token = authz.substring(7);
// BAD: parse()는 서명이 없는 JWT도 허용할 수 있음
Jwt<?, ?> jwt = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parse(token);
Claims claims = (Claims) jwt.getBody(); // 서명 미검증 상태의 claim 사용
return claims.getSubject();
}
}
변경 후
java
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import org.springframework.web.bind.annotation.*;
@RestController
class TokenControllerSafe {
private final SecretKey key;
TokenControllerSafe(SecretKey key) {
this.key = key;
}
@GetMapping("/me")
public String me(@RequestHeader("Authorization") String authz) {
if (!authz.startsWith("Bearer ")) {
throw new JwtException("missing bearer token");
}
String token = authz.substring(7);
// GOOD: parseClaimsJws()는 서명 검증을 강제함
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
Claims claims = jws.getBody();
// 추가 검증 예시
if (!"my-issuer".equals(claims.getIssuer())) {
throw new JwtException("invalid iss");
}
return claims.getSubject();
}
}
변경 후에는 parseClaimsJws()로 서명된 클레임을 요구하고 서명을 검증한 뒤 발급자를 확인합니다. 예시의 my-issuer는 실제 신뢰하는 발급자로 바꾸세요.