Spring Boot 보안 토큰을 Keycloak의 공개 키(JWKS)를 사용해 로컬에서 검증

발행: (2025년 12월 16일 오후 05:46 GMT+9)
11 min read
원문: Dev.to

Source: Dev.to

죄송합니다만, 번역하려는 전체 텍스트를 제공해 주시면 해당 내용을 한국어로 번역해 드리겠습니다. 현재는 링크만 제공되어 있어 실제 기사 내용이 포함되어 있지 않으므로 번역을 진행할 수 없습니다. 텍스트를 복사해서 보내주시면 바로 도와드리겠습니다.

액터

ActorRole
Keycloak (Authorization Server / IdP)• OIDC/OAuth2 흐름(인증 코드, 클라이언트 자격 증명 등)을 통해 사용자/클라이언트를 인증합니다.
iss, sub, exp, iat, nbf, aud, scope, preferred_username, realm_access 등과 같은 클레임을 포함한 JWT 액세스 토큰을 발급합니다.
• 레알름 아래 JWKS 엔드포인트에 서명 키를 공개합니다.
Spring Boot API (Resource Server)• 보호된 REST 엔드포인트를 노출합니다.
• Spring Security의 OAuth2 리소스 서버 지원을 사용하여:
 - 들어오는 요청에서 Authorization: Bearer <token>을 추출합니다.
 - Keycloak의 JWKS를 사용해 JWT 서명과 클레임을 로컬에서 검증합니다.
 - 클레임/스코프를 권한으로 매핑하고 @PreAuthorize, hasRole 등으로 접근 규칙을 적용합니다.
Client (SPA, mobile app, B2B, …)• 적절한 OAuth2 흐름을 통해 Keycloak으로부터 토큰을 획득합니다.
• Spring API를 호출할 때 액세스 토큰을 Bearer 토큰으로 전송합니다.

핵심 포인트: 토큰이 발급된 후, Spring 서비스는 토큰을 검증하기 위해 Keycloak의 공개 키만 필요하며, 매 요청마다 Keycloak에 호출할 필요가 없습니다.

Token Issuance Flow (Simplified)

  1. User or backend client 가 로그인합니다 (브라우저 리다이렉트 / OIDC 또는 client‑credentials).

  2. Keycloak 이 자격 증명을 검증하고, 사용자의 아이덴티티와 역할을 구성한 뒤 access token (필요에 따라 refresh token도) 을 발급합니다.

  3. 발급된 access token 은 JWT 로, Keycloak 의 개인 키( kid 로 식별) 로 서명됩니다.

  4. Keycloak 은 해당 public keys 를 다음 경로에서 공개합니다:

    https://<host>/realms/<realm>/.well-known/openid-configuration   (metadata)
    https://<host>/realms/<realm>/protocol/openid-connect/certs   (JWKS)
  5. 클라이언트는 이제 자체 포함된 서명된 JWT 를 보유하게 됩니다. 리소스 서버가 공개 키를 알고 있는 한, 검증을 위해 Keycloak 에 추가 호출을 할 필요가 없습니다.

Spring Security를 OAuth2 리소스 서버로 구성하기

옵션 A – Issuer‑URI (권장)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
  • Spring은 발급자를 위해 /.well-known/openid-configuration에 요청합니다.
  • 메타데이터에서 jwks_uri를 읽어옵니다.
  • Keycloak JWKS 엔드포인트를 사용해 JwtDecoder를 생성합니다.
  • 발급자 검증을 강제합니다 (iss 클레임이 설정된 발급자와 일치해야 함).

장점: 간단하고 견고합니다 – Keycloak이 JWKS URL을 변경해도 발급자가 동일하면 메타데이터가 일관성을 유지합니다.

옵션 B – JWKS‑Set‑URI (직접)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
  • 발견 과정을 건너뛰고 JWKS 엔드포인트를 직접 지정합니다.
  • 보통 issuer-uri와 함께 사용하거나 커스텀 검증기를 추가해 iss 검증을 유지합니다.

JWKS 캐싱

Spring의 기본 JWT 지원(Nimbus)은 키를 캐시합니다:

  • 필요에 따라 한 번 (또는 필요 시) Keycloak에서 JWKS를 가져옵니다.
  • 키를 메모리에 저장합니다.
  • 캐시 정책에 따라 새 kid가 키 회전 후 나타날 때 등 갱신합니다.

결과: 요청당 호출 없음 to Keycloak; 캐시가 만료되거나 새 키가 도입될 때만 가끔 가져옵니다.

요청 처리 흐름 (API 호출당)

  1. 들어오는 HTTP 요청

    Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...
  2. Bearer 토큰 추출 – Spring Security의 필터 체인이 토큰을 추출하고 설정된 JwtDecoder에 전달합니다.

  3. 토큰 파싱 및 서명 검증

    • JWT 헤더 디코딩 → kid 읽기.
    • JWKS 캐시에서 일치하는 공개 키 조회.
    • 서명 검증 (예: RS256).
    • 검증에 실패하면 인증이 거부됩니다.
  4. 클레임 검증 (표준 검사)

    • exp – 만료되지 않음.
    • nbf – 현재 유효.
    • iat – 선택적 사용자 정의 검증.
    • iss – 설정된 발행자와 일치해야 함.
    • aud – 선택적이며, API가 기대하는 audience/client‑id와 일치해야 함.
  5. 인증 객체 생성

    • Jwt 객체가 생성되고 토큰 클레임에서 파생된 권한을 가진 JwtAuthenticationToken으로 래핑됩니다.
    • SecurityContextHolder에 저장됩니다.
  6. 컨트롤러/서비스 수준에서의 인가

    @PreAuthorize("hasAuthority('SCOPE_my-scope')")
    @PreAuthorize("hasRole('admin')")   // after mapping

    결정은 검증된 토큰만을 기반으로 이루어지며 Keycloak 호출이 없습니다.

  7. 실패 처리 – 토큰 누락/무효, 서명 오류, 발행자 불일치, 혹은 토큰 만료 시 401 Unauthorized 또는 403 Forbidden이 반환됩니다.

Maven 의존성

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
    <optional>true</optional>
</dependency>

예시 application.yml (부분)

server:
  port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # 두 옵션 중 하나를 선택하세요:
          issuer-uri: https://keycloak.example.com/realms/my-realm
          # jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs

TL;DR

  • Keycloak → 서명된 JWT를 발행하고 JWKS를 공개합니다.
  • Spring Boot (Resource Server) → 캐시된 JWKS를 사용해 JWT를 로컬에서 검증합니다 (issuer-uri 또는 jwk-set-uri 통해).
  • Clients → JWT를 Bearer 토큰으로 전송합니다.

Keycloak에 매 요청마다 라운드‑트립이 필요하지 않으며, 키 회전, 클레임‑기반 보안, 그리고 Spring의 강력한 권한 부여 어노테이션을 그대로 활용할 수 있습니다.

port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm

보안 구성 (Java)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            );

        return http.build();
    }
}

Keycloak 역할을 Spring 권한에 매핑하기

Keycloak은 역할을 realm_access.rolesresource_access.<client>.roles 와 같은 클레임에 넣습니다. 아래 컨버터는 이러한 역할을 Spring의 ROLE_ 권한으로 매핑합니다:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

@Configuration
public class JwtConverterConfig {

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter();

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> defaultAuthorities = defaultConverter.convert(jwt);

            Collection<GrantedAuthority> realmRoles = jwt.getClaimAsMap("realm_access") != null
                    ? ((Collection<String>) ((Map<?, ?>) jwt.getClaim("realm_access"))
                        .getOrDefault("roles", List.of()))
                        .stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                        .toList()
                    : List.of();

            return Stream.concat(defaultAuthorities.stream(), realmRoles.stream())
                    .toList();
        });

        return converter;
    }
}

컨버터를 보안 설정에 연결합니다:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)

이제 @PreAuthorize("hasRole('admin')") 와 같은 어노테이션을 사용하면 Keycloak의 레임 역할 admin과 연동됩니다.

추가 JWT 검증 (Audience, Tenant 등)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.*;

import java.util.List;

@Configuration
public class JwtDecoderConfig {

    private static final String EXPECTED_AUDIENCE = "my-api";

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder =
                JwtDecoders.fromIssuerLocation("https://keycloak.example.com/realms/my-realm");

        OAuth2TokenValidator<Jwt> withIssuer =
                JwtValidators.createDefaultWithIssuer("https://keycloak.example.com/realms/my-realm");

        OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
            List<String> audiences = jwt.getAudience();
            if (audiences != null && audiences.contains(EXPECTED_AUDIENCE)) {
                return OAuth2TokenValidatorResult.success();
            }
            OAuth2Error error = new OAuth2Error("invalid_token",
                    "The required audience is missing", null);
            return OAuth2TokenValidatorResult.failure(error);
        };

        jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
        return jwtDecoder;
    }
}

디코더를 보안 설정에 연결합니다:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.decoder(jwtDecoder()))
)

JWKS 캐싱 및 회전 감지

JWKS를 더 자주(예: 1분마다) 가져오면서 검증을 로컬에서 유지하려면:

  1. NimbusJwtDecoder(fromIssuerLocation 또는 withJwkSetUri) 사용.
  2. 캐싱 레이어(Spring Cache, Caffeine, Redis 등)와 함께 JWKS 소스를 커스터마이징.
  3. JWKS 엔트리에 TTL(예: 60 초) 설정.

이 접근 방식은 각 인스턴스가 주기적으로 JWKS를 새로 고쳐 키 회전을 빠르게 감지하게 하며, 요청 수준 성능에 영향을 주지 않습니다.

Tip: 더 나은 성능과 결합도 감소를 위해 토큰 인트로스펙션보다 JWT 검증(리소스‑서버 모드)을 선호하세요.

핵심 속성 요약

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
  • SecurityFilterChain을 역할 기반 접근으로 구성합니다.
  • Keycloak 영역 역할을 ROLE_ 권한으로 매핑하기 위해 사용자 정의 JwtAuthenticationConverter를 제공합니다.
  • audience(또는 기타 클레임) 검증을 위한 선택적 JwtDecoder 빈을 추가합니다.
  • 필요에 따라 키 회전을 감지하도록 JWKS 캐시 TTL을 조정합니다(예: 60 초마다).

Keycloak Integration Tips

  • jwk-set-uri – Spring이 Keycloak의 JWKS를 가져올 수 있도록 이 값을 설정하세요.
  • 매 요청마다 Keycloak에 호출하는 것을 피하기 위해 JWKS 캐싱을 활용하세요; 회전 민감도와 부하를 고려해 캐시 TTL을 조정합니다.
  • JwtDecoder 검증기를 통해 iss, exp, nbf, aud와 같은 클레임(및 도메인별 제약) 을 강제하세요.
  • 커스텀 JwtAuthenticationConverter 로 Keycloak 역할/클레임을 Spring 권한에 매핑하여 인가 로직을 깔끔하게 유지하세요.
Back to Blog

관련 글

더 보기 »