JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략
4편에서 세션 기반 vs Stateless를 비교했죠. 이 글은 Stateless 쪽의 핵심인 JWT를 깊게 파고듭니다. 만료·클레임·키 회전(kid)을 설계하고, RS256 + JWK로 검증 키를 안전하게 배포하는 방법을 예제로 보여줍니다. 목표는 “다중 인스턴스/다중 서비스 환경에서도 안전하고 예측 가능한 토큰 수명 관리”입니다.
1) Access/Refresh TTL 전략
- Access: 5–30분(짧게). 탈취 피해 축소와 빠른 회전을 위해.
- Refresh: 7–30일(길게). 서버 저장소(DB/Redis)에 유지해 강제 로그아웃/블랙리스트 가능.
- 재발급 시 회전(rotating): 사용된 Refresh는 즉시 폐기하고 새 Refresh 발급.
2) 권장 클레임 셋
iss: 발급자(예: https://auth.example.com)
aud: 대상(예: api.example.com)
sub: 사용자 식별자(예: userId 또는 email)
exp/iat/nbf: 만료/발급/Not-Before
scope/roles: 권한(문자열 또는 배열)
jti: 토큰 식별자(중복 사용 방지/로그 추적)
3) RS256 + JWK로 서명/검증
HMAC(대칭) 대신 RSA(ECDSA) 비대칭을 쓰면 서명용(private)과 검증용(public)을 분리해 서비스 간 배포가 쉽습니다. kid를 헤더에 넣고 JWK Set 엔드포인트에서 공개 키를 제공하세요.
3-1) 키/인코더/디코더 구성
@Configuration
public class JwtKeyConfig {
// 데모용: 애플리케이션 기동 시 메모리에 키 생성(운영: KMS/HSM 또는 키 파일 로드)
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey k1 = new RSAKeyGenerator(2048).keyID("k1").generate();
RSAKey k2 = new RSAKeyGenerator(2048).keyID("k2").generate(); // 예: 예비키
JWKSet set = new JWKSet(k1, k2);
return (selector, ctx) -> selector.select(set);
}
@Bean
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource); // RS256 서명에 private key 사용
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return NimbusJwtDecoder.withJwkSource(jwkSource).build(); // kid로 공개키 선택
}
}
3-2) JWK Set 공개 엔드포인트
검증 서비스(리소스 서버)가 kid로 공개키를 가져갈 수 있도록 노출합니다.
@RestController
class JwksController {
private final JWKSource<SecurityContext> source;
JwksController(JWKSource<SecurityContext> source) { this.source = source; }
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> keys() throws Exception {
JWKSelector selector = new JWKSelector(new JWKMatcher.Builder().publicOnly(true).build());
List<JWK> jwks = this.source.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
// 공개 키만 노출
List<JWK> publics = jwks.stream().map(j -> j.toPublicJWK()).toList();
return new JWKSet(publics).toJSONObject(true);
}
}
4) 토큰 발급 서비스(JWT + Refresh 회전)
@Service
@RequiredArgsConstructor
public class TokenService {
private final JwtEncoder encoder;
private final RefreshTokenRepo repo;
public String access(String sub, List<String> roles, Duration ttl) {
Instant now = Instant.now();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).type("JWT").build();
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(sub).issuedAt(now).expiresAt(now.plus(ttl))
.issuer("https://auth.example.com")
.claim("roles", roles)
.build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
@Transactional
public Map<String, String> rotateRefresh(String sub, String oldRid) {
if (oldRid != null) repo.deleteById(oldRid); // 이전 토큰 폐기
String rid = UUID.randomUUID().toString();
repo.save(new RefreshToken(rid, sub, Instant.now().plus(14, ChronoUnit.DAYS)));
String refresh = access(sub, List.of("refresh"), Duration.ofDays(14)); // typ=refresh로 구분해도 OK
return Map.of("refreshToken", refresh, "rid", rid);
}
}
5) 리소스 서버(검증 측) 설정
같은 애플리케이션이라면 위의 jwtDecoder 빈을 재사용하면 됩니다. 별도 서비스라면 jwk-set-uri로 연동하세요.
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
6) 키 회전 시나리오
- 새 키 생성(kid=k2) → JWK Set에 k1(기존)+k2(새 키) 모두 노출
- 서명 키 전환: 인코더가 k2를 사용해 서명(헤더 kid=k2)
- 그레이스 기간: k1으로 서명된 토큰 만료까지 검증 키로 유지
- k1 삭제: 모든 k1 토큰 만료 후 JWK에서 제거
7) 보안 체크
- Access 만료는 짧게, Refresh는 서버 저장소로 통제(로그아웃/회수)
- 필수 클레임 누락 금지(iss/aud/exp), 클럭 스큐(±1–3분) 허용
- 민감 정보는 절대 JWT에 넣지 않기(클라이언트에서 읽힐 수 있음)
- HTTPS 필수, 쿠키 사용 시
Secure/HttpOnly/SameSite고려
⏩ 다음 글 보기: OAuth2 로그인(Google/GitHub) + JWT 브릿지
👉 1편: SecurityFilterChain 완전정복: 요청 매칭/인가 룰 제대로 이해하기
👉 2편: PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책
👉 3편: AuthenticationProvider & UserDetailsService 커스터마이징
👉 4편: 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리
👉 5편: JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략
👉 6편: OAuth2 로그인(Google/GitHub) + JWT 브릿지
👉 7편: 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한
👉 8편: CORS·XSS·헤더 보안: SPA/REST 현실 설정
'Java & Spring' 카테고리의 다른 글
| 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한 (0) | 2025.10.14 |
|---|---|
| OAuth2 로그인(Google/GitHub) + JWT 브릿지 (0) | 2025.10.13 |
| 세션 기반 로그인 vs Stateless: CSRF/Remember-me/세션관리 (0) | 2025.10.12 |
| AuthenticationProvider & UserDetailsService 커스터마이징 (0) | 2025.10.12 |
| PasswordEncoder와 회원가입: BCrypt·Pepper·비밀번호 정책 (1) | 2025.10.11 |