Java 진영 대표 JWT 라이브러리 JJWT가 0.12.x로 메이저 업데이트되면서
JWT 생성/검증, 키 관리, 암호화, 빌더 패턴 등이 대폭 개선되었습니다.
이 글에서는 JJWT 0.11.x 이하 → 0.12.x 이상으로 마이그레이션할 때
실무 개발자가 반드시 알아야 할 모든 변경점을 상세히 다룹니다.
signWith(SignatureAlgorithm, key)
→ signWith(key)
(알고리즘 자동 추론)setIssuer()
, setSubject()
→ issuer()
, subject()
(set 접두사 제거)verifyWith(key)
또는 keyLocator()
필수WeakKeyException
발생import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
private String generateToken(User user) {
Date now = new Date();
Date expiry = new Date(now.getTime() + 3600000); // 1시간
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("my-service")
.setSubject(user.getEmail())
.setIssuedAt(now)
.setExpiration(expiry)
.claim("userId", user.getId())
.claim("roles", user.getRoles())
.signWith(SignatureAlgorithm.HS256, "my-secret-key") // ❌ 위험
.compact();
}
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
public class JwtService {
// 1. 안전한 키 생성 (앱 시작 시 한 번만)
private final SecretKey secretKey = Jwts.SIG.HS256.key().build();
// 2. 또는 기존 키가 있다면 안전하게 복원
// private final SecretKey secretKey = Keys.hmacShaKeyFor(
// Decoders.BASE64.decode("your-base64-encoded-key")
// );
public String generateToken(User user) {
Instant now = Instant.now();
return Jwts.builder()
.header() // ✅ 헤더 빌더
.type("JWT")
.keyId("my-key-id") // 키 식별자 (옵션)
.and() // 헤더 빌더 종료
.issuer("my-service") // ✅ set 접두사 제거
.subject(user.getEmail())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(3600)))
.claim("userId", user.getId())
.claim("roles", user.getRoles())
.signWith(secretKey) // ✅ 키만 전달 (알고리즘 자동)
.compact();
}
}
Jwts.SIG.HS256.key().build()
로 충분한 길이의 키 자동 생성signWith(key)
만 사용, 키 타입에서 알고리즘 자동 결정.header().type().keyId().and()
체인으로 헤더 설정setIssuer()
→ issuer()
, setSubject()
→ subject()
등public Claims parseToken(String token) {
try {
return Jwts.parser()
.setSigningKey("my-secret-key") // ❌ 평문 키 사용
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
throw new InvalidTokenException("Invalid token", e);
}
}
public class JwtService {
private final SecretKey secretKey = getSecretKey(); // 위에서 생성한 키
// 1. 기본 파싱 방식
public Claims parseToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey) // ✅ 검증 키 지정
.build() // ✅ 파서 빌드
.parseSignedClaims(token) // ✅ 타입 안전한 파싱
.getPayload(); // ✅ getBody() → getPayload()
} catch (JwtException e) {
throw new InvalidTokenException("Token validation failed", e);
}
}
// 2. 클레임 검증과 함께 파싱
public Claims parseTokenWithValidation(String token, String expectedIssuer) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.requireIssuer(expectedIssuer) // ✅ 발급자 검증
.clockSkewSeconds(60) // ✅ 시간 오차 허용
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
throw new TokenExpiredException("Token expired", e);
} catch (InvalidClaimException e) {
throw new InvalidClaimException("Invalid claims", e);
}
}
// 3. 동적 키 조회 (멀티 테넌트 환경)
public Claims parseTokenWithKeyLocator(String token) {
return Jwts.parser()
.keyLocator(header -> { // ✅ 헤더 기반 키 조회
String keyId = header.getKeyId();
return keyRepository.findByKeyId(keyId);
})
.build()
.parseSignedClaims(token)
.getPayload();
}
}
.build()
메서드로 파서 생성 필요parseSignedClaims()
, parseEncryptedClaims()
등 타입별 메서드verifyWith()
또는 keyLocator()
반드시 지정requireIssuer()
, requireSubject()
등으로 클레임 검증// HMAC 알고리즘용 SecretKey
SecretKey hs256Key = Jwts.SIG.HS256.key().build(); // 256비트 (32바이트)
SecretKey hs384Key = Jwts.SIG.HS384.key().build(); // 384비트 (48바이트)
SecretKey hs512Key = Jwts.SIG.HS512.key().build(); // 512비트 (64바이트)
// RSA 알고리즘용 KeyPair
KeyPair rs256Pair = Jwts.SIG.RS256.keyPair().build(); // 2048비트 이상
KeyPair rs512Pair = Jwts.SIG.RS512.keyPair().build(); // 4096비트 권장
// ECDSA 알고리즘용 KeyPair
KeyPair es256Pair = Jwts.SIG.ES256.keyPair().build(); // P-256 곡선
KeyPair es384Pair = Jwts.SIG.ES384.keyPair().build(); // P-384 곡선
KeyPair es512Pair = Jwts.SIG.ES512.keyPair().build(); // P-521 곡선
// EdDSA 알고리즘용 KeyPair (JDK 15+ 또는 BouncyCastle 필요)
KeyPair eddsaPair = Jwts.SIG.EdDSA.keyPair().build(); // Ed25519 또는 Ed448
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
public class KeyManager {
// 키를 안전하게 저장하기 위한 Base64 인코딩
public String encodeKey(SecretKey key) {
return Encoders.BASE64.encode(key.getEncoded());
}
// 저장된 키 복원
public SecretKey decodeKey(String encodedKey) {
byte[] keyBytes = Decoders.BASE64.decode(encodedKey);
return Keys.hmacShaKeyFor(keyBytes);
}
// 평문 패스워드에서 키 생성 (권장하지 않음)
public SecretKey fromPassword(String password) {
// ⚠️ 실제 운영환경에서는 PBKDF2 등 키 유도 함수 사용 권장
return Keys.hmacShaKeyFor(password.getBytes(StandardCharsets.UTF_8));
}
// 환경변수에서 키 로드
public SecretKey loadFromEnvironment() {
String encodedKey = System.getenv("JWT_SECRET_KEY");
if (encodedKey == null) {
throw new IllegalStateException("JWT_SECRET_KEY environment variable not set");
}
return decodeKey(encodedKey);
}
}
String jwt = Jwts.builder()
.header()
.type("JWT") // typ 헤더
.keyId("key-2024-01") // kid 헤더 (키 식별)
.add("customHeader", "customValue") // 커스텀 헤더
.and() // 헤더 빌더 종료
// 표준 클레임들
.issuer("https://auth.example.com")
.subject("user@example.com")
.audience().add("api.example.com").and() // aud 클레임 (여러 값)
.expiration(Date.from(Instant.now().plusSeconds(3600)))
.notBefore(Date.from(Instant.now()))
.issuedAt(new Date())
.id(UUID.randomUUID().toString()) // jti 클레임
// 커스텀 클레임들
.claim("userId", user.getId())
.claim("roles", Arrays.asList("USER", "ADMIN"))
.claim("permissions", user.getPermissions())
.signWith(secretKey)
.compact();
// 1. Claims 기반 JWT (가장 일반적)
String claimsJwt = Jwts.builder()
.subject("user123")
.claim("role", "admin")
.signWith(key)
.compact();
// 2. 바이너리 컨텐츠 JWT
byte[] imageData = loadImageBytes();
String contentJwt = Jwts.builder()
.content(imageData, "image/jpeg") // 컨텐츠 + MIME 타입
.signWith(key)
.compact();
// 3. JSON 문자열 컨텐츠
String jsonString = "{\"data\":\"value\"}";
String jsonJwt = Jwts.builder()
.content(jsonString, "application/json")
.signWith(key)
.compact();
import io.jsonwebtoken.security.AeadAlgorithm;
import io.jsonwebtoken.security.KeyAlgorithm;
public class JweService {
// 1. 직접 암호화 (동일한 당사자간 사용)
public String createDirectEncryptedJwt(String subject) {
SecretKey key = Jwts.ENC.A256GCM.key().build();
return Jwts.builder()
.subject(subject)
.claim("sensitive", "confidential-data")
.encryptWith(key, Jwts.KEY.DIRECT, Jwts.ENC.A256GCM)
.compact();
}
// 2. RSA 키 암호화 (수신자의 공개키로 암호화)
public String createRsaEncryptedJwt(RSAPublicKey recipientPublicKey) {
return Jwts.builder()
.subject("alice@example.com")
.claim("salary", 100000)
.encryptWith(recipientPublicKey, Jwts.KEY.RSA_OAEP_256, Jwts.ENC.A256GCM)
.compact();
}
// 3. 패스워드 기반 암호화
public String createPasswordEncryptedJwt(char[] password) {
Password pwd = Keys.password(password);
return Jwts.builder()
.subject("user123")
.encryptWith(pwd, Jwts.KEY.PBES2_HS256_A128KW, Jwts.ENC.A256GCM)
.compact();
}
// JWE 복호화
public Claims parseEncryptedJwt(String jwe, SecretKey decryptionKey) {
return Jwts.parser()
.decryptWith(decryptionKey)
.build()
.parseEncryptedClaims(jwe)
.getPayload();
}
}
import io.jsonwebtoken.security.*;
public class JwkService {
// JWK 생성
public SecretJwk createSecretJwk(SecretKey key) {
return Jwks.builder()
.key(key)
.idFromThumbprint() // 썸프린트를 키 ID로 사용
.operations().add(KeyOperation.SIGN) // 허용된 키 연산 지정
.and()
.build();
}
// JWK JSON 문자열로 변환
public String jwkToJson(Jwk<?> jwk) {
return new JacksonSerializer().serialize(jwk);
}
// JSON에서 JWK 파싱
public Jwk<?> parseJwk(String jwkJson) {
return Jwks.parser()
.build()
.parse(jwkJson);
}
// JWK에서 Java Key 추출
public Key jwkToKey(Jwk<?> jwk) {
return jwk.toKey();
}
// JWK Set 생성 (여러 키 관리)
public JwkSet createJwkSet(List<Jwk<?>> jwks) {
return Jwks.set()
.add(jwks)
.build();
}
}
// ❌ 이제 이런 코드는 WeakKeyException 발생
try {
String weakKey = "short"; // 너무 짧은 키
Jwts.builder()
.subject("test")
.signWith(Keys.hmacShaKeyFor(weakKey.getBytes()))
.compact();
} catch (WeakKeyException e) {
// HMAC-SHA256은 최소 32바이트 필요
log.error("Key is too weak: {}", e.getMessage());
}
// ✅ 올바른 방식
SecretKey strongKey = Jwts.SIG.HS256.key().build(); // 자동으로 충분한 길이 생성
public Claims parseTokenSafely(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.clockSkewSeconds(60) // 시간 오차 60초 허용
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
log.warn("Token expired for subject: {}", e.getClaims().getSubject());
throw new TokenExpiredException("Token has expired");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT format: {}", e.getMessage());
throw new InvalidTokenException("Unsupported token format");
} catch (MalformedJwtException e) {
log.error("Malformed JWT: {}", e.getMessage());
throw new InvalidTokenException("Malformed token");
} catch (SignatureException e) {
log.error("Invalid JWT signature: {}", e.getMessage());
throw new InvalidTokenException("Invalid token signature");
} catch (InvalidClaimException e) {
log.error("Invalid JWT claim: {}", e.getMessage());
throw new InvalidTokenException("Invalid token claims");
}
}
// Unprotected JWT 방지 - 반드시 서명 또는 암호화 필요
try {
Jwts.builder()
.subject("test")
// .signWith() 또는 .encryptWith() 없음
.compact(); // ❌ 경고: 보안되지 않은 JWT 생성
} catch (Exception e) {
log.warn("Attempted to create unprotected JWT");
}
// ✅ 올바른 방식 - 반드시 보안 적용
String secureJwt = Jwts.builder()
.subject("test")
.signWith(secretKey) // 또는 .encryptWith(...)
.compact();
Maven
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Gradle
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
signWith(SignatureAlgorithm, key)
→ signWith(key)
변경setXxx()
메서드들 → xxx()
메서드로 변경.build()
추가 및 verifyWith()
또는 keyLocator()
지정parseClaimsJws()
→ parseSignedClaims()
변경getBody()
→ getPayload()
변경WeakKeyException
대응)@Test
public void testJwtCreationAndParsing() {
// Given
SecretKey key = Jwts.SIG.HS256.key().build();
String subject = "test-user";
// When - JWT 생성
String jwt = Jwts.builder()
.subject(subject)
.issuedAt(new Date())
.expiration(Date.from(Instant.now().plusSeconds(3600)))
.signWith(key)
.compact();
// Then - JWT 파싱 및 검증
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(jwt)
.getPayload();
assertThat(claims.getSubject()).isEqualTo(subject);
assertThat(claims.getExpiration()).isAfter(new Date());
}
@Test
public void testInvalidTokenHandling() {
SecretKey key = Jwts.SIG.HS256.key().build();
String invalidToken = "invalid.jwt.token";
assertThatThrownBy(() -> {
Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(invalidToken);
}).isInstanceOf(JwtException.class);
}
application.yml
jwt:
secret-key: ${JWT_SECRET_KEY:#{null}} # 환경변수에서 로드
expiration-seconds: 3600
issuer: "https://api.example.com"
clock-skew-seconds: 60
Configuration 클래스
@Configuration
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {
private String secretKey;
private long expirationSeconds = 3600;
private String issuer;
private long clockSkewSeconds = 60;
@Bean
public SecretKey jwtSecretKey() {
if (StringUtils.hasText(secretKey)) {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
} else {
// 개발 환경에서만 자동 생성 (운영환경에서는 반드시 설정 필요)
SecretKey generated = Jwts.SIG.HS256.key().build();
log.warn("JWT secret key auto-generated. Set JWT_SECRET_KEY in production!");
return generated;
}
}
// getters and setters...
}
JJWT 0.12.x는 "Secure by Default" 철학을 바탕으로 설계되었습니다.
궁금한 점이나 마이그레이션 과정에서 겪은 이슈가 있다면 댓글로 자유롭게 공유해주세요!
실무에서 도움이 되는 추가 팁이나 개선사항도 언제든 환영합니다. 🚀