본문 바로가기
Java & Spring

JWT 심화: 만료/클레임/키 회전 + 4편과의 연결 전략

by yamoojin83 2025. 10. 13.

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) 키 회전 시나리오

  1. 새 키 생성(kid=k2) → JWK Set에 k1(기존)+k2(새 키) 모두 노출
  2. 서명 키 전환: 인코더가 k2를 사용해 서명(헤더 kid=k2)
  3. 그레이스 기간: k1으로 서명된 토큰 만료까지 검증 키로 유지
  4. k1 삭제: 모든 k1 토큰 만료 후 JWK에서 제거

7) 보안 체크

  • Access 만료는 짧게, Refresh는 서버 저장소로 통제(로그아웃/회수)
  • 필수 클레임 누락 금지(iss/aud/exp), 클럭 스큐(±1–3분) 허용
  • 민감 정보는 절대 JWT에 넣지 않기(클라이언트에서 읽힐 수 있음)
  • HTTPS 필수, 쿠키 사용 시 Secure/HttpOnly/SameSite 고려

 

 

👉 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 현실 설정

👉 9편: 리소스 서버 분리 & API Gateway 연동(Spring Cloud Gateway)

👉 10편: 감사 로깅/Audit: 로그인 실패·권한거부 탐지 규격