JWT 서명 검증 누락

인증 클레임을 신뢰하기 전에 JWT 서명 검증

설명

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는 실제 신뢰하는 발급자로 바꾸세요.

참조