Bldev's Blog

[스프링/보안] 스프링 시큐리티의 필터 체인과 인증/인가

2026. 10. 5.

스프링 시큐리티

스프링 시큐리티(Spring Security)는 스프링 애플리케이션의 인증(authentication), 인가(authorization), 그리고 CSRF나 보안 헤더 같은 일반적인 웹 공격 방어를 제공하는 보안 프레임워크이다. 인증은 요청을 보낸 주체가 누구인지 확인하는 것이고, 인가는 확인된 주체가 요청한 자원에 접근할 권한이 있는지 판단하는 것이다.

스프링 시큐리티의 핵심 동작 방식은 필터 체인(filter chain)이다. 보안 기능은 컨트롤러나 서비스 코드에 들어가지 않고 요청이 컨트롤러에 도달하기 전에 거치는 필터(filter)들을 통해 구현된다. 따라서 애플리케이션 코드는 보안 로직과 분리되고 보안 기능은 필터의 구성과 순서로 결정되므로 보안 기능에 대한 관심사를 도메인 비즈니스 로직과 분리할 수 있다는 장점이 있다.

필터 체인 구조

스프링 MVC(서블릿)와 스프링 웹플럭스(리액티브)는 같은 개념을 서로 다른 타입으로 구현한다. 웹 컨테이너의 구동 방식 및 요청 처리 모델이 근본적으로 다르기 때문에 관련 구현체와 처리 방식에서도 차이가 발생한다.

구분 스프링 MVC 스프링 WebFlux
필터 타입 jakarta.servlet.Filter org.springframework.web.server.WebFilter
체인 진입점 DelegatingFilterProxy → FilterChainProxy WebFilterChainProxy
체인 빈 SecurityFilterChain SecurityWebFilterChain
활성화 @EnableWebSecurity @EnableWebFluxSecurity
설정 빌더 HttpSecurity ServerHttpSecurity
인증 정보 접근 SecurityContextHolder ReactiveSecurityContextHolder

웹플럭스에서 보안은 WebFilterChainProxy라는 하나의 WebFilter로 등록된다. 이 필터 안에 SecurityWebFilterChain이 있고, 체인 안의 필터들(보안 헤더 설정, CSRF 검사, 인증, 예외 변환, 인가)이 SecurityWebFiltersOrder에 정의된 순서로 실행된다.

WebFilterChainProxy의 순서 값은 WebFluxSecurityConfiguration.WEB_FILTER_CHAIN_FILTER_ORDER이고 값은 -100이다. 애플리케이션이 @Component로 등록한 WebFilter에 @Order를 지정하지 않으면 가장 낮은 우선순위(LOWEST_PRECEDENCE)가 되므로, 순서를 지정하지 않은 사용자 필터는 보안 체인 뒤에서 실행된다.

체인 안 필터의 순서는 SecurityWebFiltersOrder 열거형이 정한다. Spring Security 6.2.2의 열거형 값 중 이 글과 관련된 것만 순서대로 나열하면 HTTP_HEADERS_WRITER, CORS, CSRF, REACTOR_CONTEXT, HTTP_BASIC, FORM_LOGIN, AUTHENTICATION, ANONYMOUS_AUTHENTICATION, EXCEPTION_TRANSLATION, AUTHORIZATION이다. 공식 문서도 인증 필터가 인가 필터보다 먼저 실행되어야 한다고 설명한다.

실행 순서 계층 / 위치 식별자 (SecurityWebFiltersOrder) 주요 역할
1 WebFilter (-100) WebFilterChainProxy 보안 체인 실행 위임
2 보안 체인 내부 HTTP_HEADERS_WRITER 보안 응답 헤더 설정 (nosniff 등)
3 보안 체인 내부 CORS 프리플라이트 요청 및 CORS 정책 처리
4 보안 체인 내부 CSRF 상태 변경 요청의 CSRF 토큰 검증
5 보안 체인 내부 REACTOR_CONTEXT 리액터 컨텍스트와 SecurityContext 동기화
6 보안 체인 내부 AUTHENTICATION 자격 증명 검증 및 인증 토큰 생성
7 보안 체인 내부 ANONYMOUS_AUTHENTICATION 미인증 요청에 익명 사용자 권한 부여
8 보안 체인 내부 EXCEPTION_TRANSLATION 인증·인가 예외를 HTTP 응답 코드로 변환
9 보안 체인 내부 AUTHORIZATION 경로 및 역할별 접근 제어 인가 검사
10 일반 WebFilter 사용자 정의 WebFilter 비즈니스 공통 처리 (LOWEST_PRECEDENCE)
11 핸들러 매핑 DispatcherHandler → 컨트롤러 라우팅 및 비즈니스 로직 실행

SecurityWebFilterChain 빈이 여러 개이면 요청마다 첫 번째로 일치하는 체인 하나만 선택된다(securityMatcher와 @Order로 결정). 서블릿의 FilterChainProxy도 같은 방식으로 일치하는 첫 SecurityFilterChain만 실행한다.

인증과 인가의 핵심 구성 요소

  • Authentication: 인증 요청 또는 인증 완료된 주체를 나타내는 객체이다. 주체(principal), 자격 증명(credentials), 권한(authorities)을 가진다.
  • SecurityContext: 현재 요청의 Authentication을 담는 객체이다. 리액티브 환경에서는 스레드에 묶인 ThreadLocal 대신 리액터의 Context에 담기므로 ReactiveSecurityContextHolder로 읽는다.
  • ReactorContextWebFilter: ServerSecurityContextRepository에서 읽은 SecurityContext로 ReactiveSecurityContextHolder를 초기화하는 체인 안의 필터이다.
  • ServerAuthenticationConverter: 요청에서 자격 증명을 꺼내 인증 전 Authentication 객체로 변환한다.
  • ReactiveAuthenticationManager: 인증 전 Authentication이 유효한지 판단해 인증 완료된 Authentication을 반환하거나 예외를 발생시킨다(Mono.error 방출).
  • AuthenticationWebFilter: 위 둘을 묶어 인증 흐름을 수행하고, 성공하면 SecurityContext에 결과를 저장한다.
  • ServerSecurityContextRepository: 요청 간에 SecurityContext를 유지·저장하는 저장소 인터페이스(전략)이다. 세션 기반은 WebSessionServerSecurityContextRepository, 무상태(stateless)는 NoOpServerSecurityContextRepository이다.
  • ServerAuthenticationEntryPoint: 인증되지 않은 요청에 어떻게 응답할지 결정한다(예: 401 반환, 로그인 페이지로 이동).
  • authorizeExchange: 경로와 메서드별 접근 규칙을 선언하면 인가 검사 필터가 체인에 들어간다.

인증 흐름은 다음과 같다.

요청 → ServerAuthenticationConverter (자격 증명 추출)
     → ReactiveAuthenticationManager (자격 증명 검증)
     → SecurityContext 저장 (ServerSecurityContextRepository)
     → 인가 검사 (authorizeExchange)
     → 컨트롤러

의존성만 추가했을 때의 기본 동작

spring-boot-starter-security를 추가하고 SecurityWebFilterChain 빈을 정의하지 않으면 스프링 부트가 기본 체인을 구성한다. 기본 체인은 모든 요청을 인증 대상으로 두고 HTTP Basic과 폼 로그인을 켠다. 인증 정보 없이 요청한 결과는 다음과 같다.

요청 응답
GET /hello 401, 헤더 WWW-Authenticate: Basic realm="Realm"
POST /echo 403, 본문 An expected CSRF token cannot be found

GET은 인증이 없어서 401이고, POST는 인증 이전에 CSRF 검사에서 막혀 403이다. 브라우저로 접근하면 WWW-Authenticate: Basic 헤더 때문에 사용자 이름과 비밀번호를 묻는 대화상자가 뜬다.

SecurityWebFilterChain 직접 정의

SecurityWebFilterChain 빈을 직접 정의하면 스프링 부트의 기본 자동 설정이 비활성화된다(backs off). 정의한 체인에 포함하지 않은 설정은 적용되지 않는다. 다음 설정을 사용한다.

@Configuration
@EnableWebFluxSecurity
class WebConfig {

  @Bean
  fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    http
      .csrf { csrf -> csrf.disable() }
    return http.build()
  }
}

CSRF 비활성화

CSRF(Cross-Site Request Forgery)는 브라우저가 쿠키를 자동으로 전송하는 점을 이용해 사용자가 의도하지 않은 요청을 보내게 하는 공격이다. 스프링 시큐리티는 상태 변경 요청(POST 등)에 CSRF 토큰을 요구해 이를 막는다. 쿠키 기반 세션 인증을 전제로 한 방어이므로, 쿠키 대신 요청마다 헤더로 인증 정보를 직접 실어 보내는 API에서는 보통 비활성화한다. CSRF를 켜 둔 채 올바른 인증 정보로 POST를 호출하면 403(An expected CSRF token cannot be found)이 나온다.

인가 규칙이 없는 체인

이 체인에는 authorizeExchange가 없다. 인가 규칙을 선언하지 않으면 인가 검사 필터가 체인에 들어가지 않으므로, 이 체인은 보안 헤더를 설정하는 것 말고는 요청을 막지 않는다. 체인만 둔 상태에서 GET /hello와 POST /echo를 인증 없이 호출하면 모두 200이다. 즉 @EnableWebFluxSecurity와 SecurityWebFilterChain 빈이 있다는 것과 요청이 보호된다는 것은 별개이며, 보호는 인증 방식과 인가 규칙을 선언해야 적용된다.

@SecurityScheme와 컨트롤러의 @SecurityRequirement(name = "ApiKeyAuth")는 스프링 시큐리티가 아니라 springdoc(OpenAPI)의 애노테이션이다. Swagger UI 문서에 X-API-KEY 입력 칸과 자물쇠 표시를 붙일 뿐 요청을 막지 않는다.

인증 구현 방식 두 가지

요청 헤더 X-API-KEY 값이 서버가 가진 값과 같을 때만 요청을 통과시키는 인증을 구현하는 방식은 두 가지이다.

1) 일반 WebFilter

@Component로 등록한 WebFilter가 헤더를 직접 검사하는 방식이다.

@Component
class ApiKeyValidationFilter(
  @Value("\${security.api-key}") private val validApiKey: String
) : WebFilter {
  private val pathMatcher = AntPathMatcher()
  private val whitelistPatterns = listOf("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**")

  override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
    val request = exchange.request

    if (request.method == HttpMethod.OPTIONS) {
      return chain.filter(exchange)
    }

    if (whitelistPatterns.any { pathMatcher.match(it, request.path.toString()) }) {
      return chain.filter(exchange)
    }

    val apiKey: String? = request.headers.getFirst("X-API-KEY")

    return if (apiKey != null && validApiKey == apiKey) {
      chain.filter(exchange)
    } else {
      exchange.response.statusCode = HttpStatus.UNAUTHORIZED
      exchange.response.setComplete()
    }
  }
}

이 방식의 동작을 같은 구성에서 확인한 결과는 다음과 같다.

요청 응답
키 없음 401
틀린 키 401
올바른 키 200

이 필터는 스프링 시큐리티 체인 밖에 있다. 이에 따라 다음과 같은 두 가지 동작 특성이 나타난다.

  • 필터는 보안 체인(-100) 뒤에서 실행된다. 키가 없어서 401이 난 응답에도 X-Content-Type-Options: nosniff 헤더가 붙는데, 이는 보안 체인의 보안 헤더 설정 필터(HttpHeaderWriterWebFilter)가 먼저 실행되었다는 뜻이다.
  • 필터가 요청을 통과시켜도 스프링 시큐리티는 인증된 주체를 알지 못한다. 올바른 키로 호출한 컨트롤러에서 ReactiveSecurityContextHolder로 읽은 SecurityContext는 비어 있었다. 따라서 @AuthenticationPrincipal, @PreAuthorize, authorizeExchange의 authenticated()나 hasRole() 같은 스프링 시큐리티의 인가 기능을 쓸 수 없다.

2) AuthenticationWebFilter

스프링 시큐리티의 인증 모델로 구현하면 같은 동작이 체인 안에서 이루어지고 인증 결과가 SecurityContext에 저장된다.

class ApiKeyAuthenticationToken(val apiKey: String) : AbstractAuthenticationToken(emptyList()) {
  override fun getCredentials() = apiKey
  override fun getPrincipal() = "api-client"
}

@Bean
fun apiKeyAuthenticationFilter(@Value("\${security.api-key}") validKey: String): AuthenticationWebFilter {
  val manager = ReactiveAuthenticationManager { auth ->
    val supplied = (auth as ApiKeyAuthenticationToken).apiKey
    val ok = MessageDigest.isEqual(supplied.toByteArray(), validKey.toByteArray())
    if (ok) Mono.just<Authentication>(
      UsernamePasswordAuthenticationToken("api-client", null, AuthorityUtils.createAuthorityList("ROLE_CLIENT"))
    ) else Mono.error(BadCredentialsException("invalid api key"))
  }

  return AuthenticationWebFilter(manager).apply {
    setServerAuthenticationConverter { exchange ->
      val key = exchange.request.headers.getFirst("X-API-KEY")
      if (key == null) Mono.empty() else Mono.just(ApiKeyAuthenticationToken(key))
    }
    setSecurityContextRepository(NoOpServerSecurityContextRepository.getInstance())
    setAuthenticationFailureHandler(
      ServerAuthenticationEntryPointFailureHandler(HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))
    )
  }
}
  • 컨버터는 헤더가 없으면 빈 Mono를 반환한다. 인증을 시도하지 않은 요청으로 처리되어 이후 인가 단계에서 거부된다.
  • 매니저는 키가 맞으면 인증 완료된 Authentication을, 틀리면 예외를 반환한다.
  • NoOpServerSecurityContextRepository는 인증 결과를 세션에 저장하지 않는다. 요청마다 인증하는 무상태 API에 맞다.
  • 실패 핸들러를 지정하지 않으면 틀린 키에 WWW-Authenticate: Basic 헤더가 붙은 401 응답이 반환된다.

Bearer 토큰 인증: Firebase ID 토큰 검증

앞의 두 방식은 서버와 클라이언트가 같은 정적 키를 공유하므로 요청을 보낸 사용자가 누구인지는 알 수 없다. 사용자를 식별하려면 클라이언트가 Firebase Authentication으로 로그인해 발급받은 ID 토큰(JWT)을 Authorization: Bearer <토큰> 헤더로 보내고, 서버가 이 토큰을 검증해야 한다. 사례의 프록시 서버는 별도 브랜치(login 이후)에서 이 방식을 구현한다.

JWT와 Firebase ID 토큰

JWT(JSON Web Token)는 헤더.페이로드.서명 세 부분을 점(.)으로 이어 Base64URL로 인코딩한 토큰이다. 헤더에는 서명 알고리즘(alg)과 서명에 쓴 키의 식별자(kid)가, 페이로드에는 발급자(iss), 대상(aud), 주체(sub), 만료 시각(exp) 같은 클레임(claim)이 들어 있다. 서명은 발급자가 개인키로 만들고, 검증하는 쪽은 발급자가 공개한 공개키로 서명을 확인한다. 공식 문서는 Firebase ID 토큰을 JWT로 설명하며, 서버가 이 토큰을 검증하면 토큰에서 uid를 꺼내 로그인한 사용자를 안전하게 식별할 수 있다고 안내한다.

검증에 쓰는 클레임만 추려 디코딩한 예시는 다음과 같다. 값은 예시이다.

헤더
{ "alg": "RS256", "kid": "<서명 키 식별자>" }

페이로드
{
  "iss": "https://securetoken.google.com/<projectId>",
  "aud": "<projectId>",
  "sub": "<uid>",
  "iat": 1791305839,
  "exp": 1791309444,
  "auth_time": 1791305839
}

공식 문서가 정한 ID 토큰의 검증 조건은 다음과 같다.

위치 클레임 조건
헤더 alg RS256
헤더 kid 구글이 공개한 서명 공개키 중 하나와 일치
페이로드 exp 미래
페이로드 iat 과거
페이로드 aud Firebase 프로젝트 ID
페이로드 iss https://securetoken.google.com/<projectId>
페이로드 sub 비어 있지 않은 문자열이며 uid
페이로드 auth_time 과거

서명 공개키는 공식 문서가 안내하는 https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com에서 kid별 X.509 인증서로 받을 수 있다. 같은 키를 표준 JWK Set 형식으로 제공하는 https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com도 있다. 이 주소는 공식 문서의 안내가 아니라 직접 받아서 확인한 것으로, keys 배열에 alg가 RS256이고 kid가 있는 RSA 키가 여러 개 들어 있었다.

이 조건을 검사하는 방법은 두 가지이다. Firebase Admin SDK의 verifyIdToken에 맡기거나, 스프링 시큐리티의 JWT 검증 기능으로 직접 구성한다.

Firebase Admin SDK로 검증

공식 문서는 위 조건을 직접 검사하기보다 Admin SDK의 verifyIdToken을 사용하라고 안내한다. 서버는 Firebase Admin SDK의 verifyIdToken으로 토큰을 검증한다. 공식 문서에 따르면 이 메서드는 토큰의 형식이 올바르고, 만료되지 않았고, 올바르게 서명되었을 때 디코딩된 토큰을 반환하며, 반환된 토큰에서 사용자의 uid를 꺼낼 수 있다. 두 번째 인자 checkRevoked를 true로 전달하면 토큰이 철회되었는지도 함께 확인하고, 검증에 실패하면 FirebaseAuthException이 발생한다. verifyIdToken은 결과를 바로 반환하는 동기 메서드이므로 리액티브 환경에서는 이벤트 루프 스레드를 막지 않도록 Dispatchers.IO로 옮겨 호출한다.

@Service
class FirebaseTokenVerifier(private val firebaseAuth: FirebaseAuth) {
  suspend fun verifyAndGetAuthentication(idToken: String): Authentication = withContext(Dispatchers.IO) {
    try {
      val firebaseToken = firebaseAuth.verifyIdToken(idToken, true)
      val authorities = listOf(SimpleGrantedAuthority("ROLE_USER"))
      UsernamePasswordAuthenticationToken(firebaseToken.uid, null, authorities)
    } catch (e: Exception) {
      throw SecurityException("Invalid or expired Firebase ID Token.")
    }
  }
}

검증기가 만든 Authentication을 스프링 시큐리티에 전달하는 지점은 ServerSecurityContextRepository의 load이다. 체인 안의 ReactorContextWebFilter가 요청마다 이 저장소의 load를 호출해 ReactiveSecurityContextHolder를 초기화한다. load가 SecurityContext를 반환하면 그 요청의 인증 정보가 되고, 빈 Mono를 반환하면 인증 정보가 없는 요청이 된다. save는 아무것도 하지 않도록 두어 인증 결과를 요청 사이에 저장하지 않는다(무상태).

@Component
class FirebaseSecurityContextRepository(
  private val tokenVerifier: FirebaseTokenVerifier
) : ServerSecurityContextRepository {

  override fun save(exchange: ServerWebExchange, context: SecurityContext): Mono<Void> = Mono.empty()

  override fun load(exchange: ServerWebExchange): Mono<SecurityContext> {
    val authHeader = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
    if (authHeader.isNullOrEmpty() || !authHeader.startsWith("Bearer ")) return Mono.empty()

    val idToken = authHeader.substring("Bearer ".length)
    return mono {
      try {
        val authentication = tokenVerifier.verifyAndGetAuthentication(idToken)
        SecurityContextImpl(authentication) as SecurityContext
      } catch (e: Exception) {
        null
      }
    }
  }
}

mono { ... } 블록이 null을 반환하면 빈 Mono가 되므로, 검증에 실패한 요청은 예외를 응답으로 바꾸지 않고 인증 정보가 없는 요청으로 처리된다. 그 요청을 거부할지는 인가 규칙이 결정한다. 사례의 프록시 서버는 이 저장소를 체인에 연결하고 경로별로 규칙을 선언한다.

@Bean
fun springSecurityFilterChain(
  http: ServerHttpSecurity,
  securityContextRepository: ServerSecurityContextRepository
): SecurityWebFilterChain = http
  .securityMatcher(OrServerWebExchangeMatcher(ServerWebExchangeMatchers.pathMatchers("/api/v1/events/**")))
  .csrf { it.disable() }
  .httpBasic { it.disable() }
  .formLogin { it.disable() }
  .securityContextRepository(securityContextRepository)
  .authorizeExchange {
    it.pathMatchers(HttpMethod.GET, "/api/v1/events/{contentId}/comments").permitAll()
      .pathMatchers(HttpMethod.POST, "/api/v1/events/{contentId}/comments").authenticated()
      .anyExchange().permitAll()
  }
  .exceptionHandling {
    it.authenticationEntryPoint { exchange, _ ->
      exchange.response.statusCode = HttpStatus.UNAUTHORIZED
      exchange.response.setComplete()
    }
  }
  .build()

securityMatcher는 이 체인이 적용될 경로를 제한한다. 앞에서 설명한 대로 요청마다 일치하는 첫 체인 하나만 선택되므로, 기능 영역별로 securityMatcher가 다른 체인을 여러 개 둘 수 있다. 사례의 프록시 서버도 이벤트 댓글, 아트스페이스, 산책로 영역마다 체인과 저장소를 따로 둔다.

같은 구성에서 요청을 보낸 결과는 다음과 같다. 형식이 틀린 토큰은 실제 Firebase Admin SDK(9.4.2)로 확인했고, 유효한 토큰은 서명된 실제 ID 토큰을 만들 수 없어 검증기만 대체해 확인했다.

요청 결과
댓글 작성(POST), 토큰 없음 401
댓글 작성, 형식이 틀린 토큰(Bearer garbage) 401
댓글 작성, Bearer가 아닌 Authorization 헤더 401
댓글 작성, 유효한 토큰 200, 컨트롤러에서 읽은 인증 주체는 uid
댓글 조회(GET), 토큰 없음 200, 익명
댓글 조회, 유효한 토큰 200, 인증 주체는 uid
댓글 조회, 형식이 틀린 토큰 200, 익명

세 가지를 정리할 수 있다.

  • permitAll() 경로도 유효한 토큰이 오면 인증 주체가 채워진다. 로그인 여부에 따라 응답이 달라지는 API를 같은 경로로 처리할 수 있다.
  • 틀린 토큰은 permitAll() 경로에서는 거부되지 않고 익명 요청으로 통과한다. 토큰이 틀린 요청을 일률적으로 거부해야 한다면 저장소가 아니라 인증 필터와 실패 핸들러로 구성해야 한다.
  • 저장소의 load는 인증 객체를 만들기 전에 반드시 검증기를 호출해야 한다. 헤더에서 토큰을 꺼내기만 하고 검증 없이 인증 완료된 Authentication을 반환하는 저장소로 같은 체인을 구성해 보면, Bearer garbage 같은 임의의 값으로도 authenticated() 경로가 200으로 통과했다. 토큰 문자열이 있다는 사실만으로는 인증되지 않으므로 이 호출이 빠지지 않았는지 시험으로 확인해야 한다.

사례의 프록시 서버는 검증 뒤에 uid로 Firestore의 사용자 문서를 조회하고, 없으면 새로 만든 사용자 객체를 Authentication의 주체로 사용한다. 이렇게 하면 컨트롤러가 authentication.principal로 서비스의 사용자 모델을 바로 받는다.

스프링 시큐리티 내장 방식: oauth2ResourceServer

사례 프로젝트의 구현은 아니고, 같은 토큰을 Firebase Admin SDK 없이 스프링 시큐리티의 JWT 리소스 서버(resource server) 기능으로 검증하는 대안이다. 체인에 oauth2ResourceServer { jwt { } }를 선언하면 Authorization: Bearer 헤더의 토큰을 읽어 ReactiveJwtDecoder로 서명을 포함해 검증하고, 통과하면 JwtAuthenticationToken을 SecurityContext에 저장한다. 앞에서 직접 만든 저장소나 AuthenticationWebFilter는 필요하지 않다.

ReactiveJwtDecoder에는 구글의 JWK Set 주소를 지정하고 발급자(iss)와 대상(aud) 검증기를 설정한다. JwtValidators.createDefaultWithIssuer는 iss와 만료 시각을 검증하지만 aud는 검증하지 않으므로 직접 추가해야 한다. 공식 문서도 aud 검증을 프로그램으로 추가할 수 있다고 안내한다.

@Bean
fun jwtDecoder(@Value("\${firebase.project-id}") projectId: String): ReactiveJwtDecoder {
  val decoder = NimbusReactiveJwtDecoder
    .withJwkSetUri("https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com")
    .build()

  val issuerValidator = JwtValidators.createDefaultWithIssuer("https://securetoken.google.com/$projectId")
  val audienceValidator = JwtClaimValidator<List<String>>(JwtClaimNames.AUD) { aud -> aud != null && aud.contains(projectId) }
  decoder.setJwtValidator(DelegatingOAuth2TokenValidator(issuerValidator, audienceValidator))
  return decoder
}

@Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http
  .csrf { it.disable() }
  .authorizeExchange {
    it.pathMatchers(HttpMethod.GET, "/api/v1/events/{contentId}/comments").permitAll()
      .pathMatchers(HttpMethod.POST, "/api/v1/events/{contentId}/comments").authenticated()
      .anyExchange().permitAll()
  }
  .oauth2ResourceServer { it.jwt { } }
  .build()

실험에서는 구글 주소 대신 같은 형식의 JWK Set을 제공하는 로컬 서버를 지정하고, RS256으로 직접 서명한 토큰을 사용했다. 결과는 다음과 같다.

요청 결과
댓글 작성(POST), 토큰 없음 401, WWW-Authenticate: Bearer
댓글 작성, 유효한 토큰 200, 인증 객체는 JwtAuthenticationToken, 이름은 sub(uid)
댓글 작성, 만료된 토큰 401, Jwt expired at ...
댓글 작성, iss가 다른 토큰 401, The iss claim is not valid
댓글 작성, aud가 다른 토큰 401, The aud claim is not valid
댓글 작성, kid는 같지만 다른 키로 서명한 토큰 401, Failed to validate the token
댓글 작성, JWT 형식이 아닌 문자열 401, Invalid JWT serialization
댓글 조회(GET), 토큰 없음 200, 익명
댓글 조회, 유효한 토큰 200, 인증 주체는 uid
댓글 조회, 만료되었거나 틀린 토큰 401

401 응답에는 WWW-Authenticate: Bearer error="invalid_token", error_description="..." 헤더가 붙는다. 브라우저의 Basic 인증 창을 띄우는 Basic이 아니다.

  • 앞의 저장소 방식과 달리 틀린 토큰이 permitAll() 경로에서도 401이다. Bearer 헤더가 있으면 인증을 시도하고 실패하면 거부하며, 헤더가 없는 익명 요청만 통과한다.
  • aud 검증기를 빼고 iss 검증기만 두면 aud가 다른 토큰이 200으로 통과했다. iss가 다른 토큰은 계속 거부되었다.
  • 서명과 클레임만 검증하므로 토큰이 철회되었는지는 확인하지 못한다. Admin SDK는 checkRevoked를 true로 전달하면 철회 여부까지 확인한다. 철회 확인이 필요하면 SDK 방식을, 필요하지 않다면 내장 방식을 선택할 수 있다.
  • Admin SDK의 초기화나 서비스 계정 자격 증명 없이 동작한다.
  • uid로 서비스의 사용자 모델을 조회해야 하면 jwtAuthenticationConverter로 Jwt를 원하는 AbstractAuthenticationToken으로 변환한다.

인가 규칙 선언

authorizeExchange로 경로별 접근 규칙을 선언하면 체인에 인가 검사 필터가 들어간다.

@Bean
fun chain(http: ServerHttpSecurity, apiKeyFilter: AuthenticationWebFilter): SecurityWebFilterChain =
  http
    .csrf { it.disable() }
    .httpBasic { it.disable() }
    .formLogin { it.disable() }
    .logout { it.disable() }
    .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
    .cors { }
    .authorizeExchange {
      it.pathMatchers(HttpMethod.OPTIONS).permitAll()
        .pathMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
        .anyExchange().authenticated()
    }
    .addFilterAt(apiKeyFilter, SecurityWebFiltersOrder.AUTHENTICATION)
    .exceptionHandling { it.authenticationEntryPoint(HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)) }
    .build()
  • 규칙은 선언한 순서대로 평가되어 첫 번째로 일치하는 규칙 하나만 적용된다(공식 문서도 서블릿의 AuthorizationFilter에 대해 같은 규칙을 설명한다). 그래서 구체적인 규칙을 앞에, anyExchange()를 마지막에 둔다. WebFlux에서는 anyExchange() 뒤에 다른 규칙을 추가하면 애플리케이션 시작(초기화) 시점에서 IllegalStateException(... would be unreachable because anyExchange() has already been registered)이 발생한다.
  • permitAll()은 인증 없이 허용하고 authenticated()는 인증된 주체를 요구한다. 역할이 필요하면 hasRole("CLIENT")처럼 선언한다.
  • anyExchange().authenticated()를 기본으로 두면 새 경로를 추가해도 기본적으로 보호된다.
  • httpBasic, formLogin을 비활성화하고 authenticationEntryPoint를 지정하지 않으면, 미인증 요청에 WWW-Authenticate: Basic이 붙은 401 응답이 반환된다. 이 헤더는 httpBasic을 꺼도 붙으므로 인증 진입점(AuthenticationEntryPoint)도 직접 지정해야 한다.
  • addFilterAt(..., SecurityWebFiltersOrder.AUTHENTICATION)은 체인 안의 인증 위치에 필터를 넣는다.

같은 요청을 두 방식으로 호출한 결과를 비교하면 다음과 같다.

요청 WebFilter AuthenticationWebFilter
키 없음 401 401
틀린 키 401 401
올바른 키 200 200
컨트롤러에서 읽은 인증 주체 없음 api-client

응답은 같고 인증 주체가 SecurityContext에 들어간다는 점이 다르다. 인증 주체가 있으면 ReactiveAuthenticationManager가 키마다 다른 역할(예: ROLE_MOBILE, ROLE_BATCH)을 부여하고 authorizeExchange에서 hasRole로 경로를 구분하는 구성이 가능하다.

CORS와 보안 체인

브라우저는 교차 출처 요청 전에 OPTIONS 프리플라이트 요청을 보낸다. 이 요청에는 X-API-KEY 같은 사용자 정의 헤더나 자격 증명이 실리지 않으므로, 인증이나 인가가 프리플라이트를 막으면 브라우저는 CORS 오류만 표시한다.

CORS를 WebFluxConfigurer.addCorsMappings로 설정하면, 이 설정은 보안 체인이 아니라 핸들러 매핑 단계에서 처리되므로 프리플라이트가 그 단계에 도달하려면 앞의 필터가 OPTIONS를 통과시켜야 한다. 그래서 앞서 WebFilter에 OPTIONS 예외를 둔 것이다. 예외를 제거하면 프리플라이트가 401이 되고 Access-Control-Allow-Origin 헤더가 없다.

스프링 시큐리티 체인에서는 .cors { }와 CorsConfigurationSource 빈으로 CORS를 처리한다.

@Bean
fun corsConfigurationSource(@Value("\${app.cors.origins}") origins: List<String>): CorsConfigurationSource {
  val cfg = CorsConfiguration().apply {
    allowedOrigins = origins
    allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
    allowedHeaders = listOf("*")
    allowCredentials = true
    maxAge = 3600
  }
  return UrlBasedCorsConfigurationSource().apply { registerCorsConfiguration("/**", cfg) }
}

authorizeExchange를 선언한 체인에서 프리플라이트가 어떻게 되는지 조합별로 확인한 결과는 다음과 같다.

구성 프리플라이트 결과
.cors { } + CorsConfigurationSource 빈, OPTIONS 허용 선언 없음 200, CORS 헤더 있음
CorsConfigurationSource 빈, OPTIONS permitAll 200, CORS 헤더 있음
CORS 설정 없음, OPTIONS permitAll 403
CORS 설정 없음, OPTIONS 허용 선언 없음 401

공식 문서에 따르면 보안 체인에 .cors { }를 선언하면 컨텍스트의 CorsConfigurationSource 빈을 기본으로 사용해 CorsWebFilter를 체인에 등록한다. 만약 체인에 .cors { }를 선언하지 않고 CorsConfigurationSource 빈만 둔다면, OPTIONS 프리플라이트 요청을 permitAll()로 허용해야 보안 체인을 통과해 WebFlux 핸들러 매핑 단계의 CORS 처리에 도달할 수 있다(위 표의 2번째 행). 반면 체인에 .cors { }를 선언하면 CORS 필터가 SecurityWebFiltersOrder에서 CSRF와 AUTHENTICATION보다 앞서 실행되므로 OPTIONS 요청을 따로 허용(permitAll)하지 않아도 프리플라이트가 인증과 인가에 막히지 않는다(1번째 행). WebFluxConfigurer로 CORS를 설정한 채 authorizeExchange를 도입한다면 CORS를 체인으로 옮기거나 OPTIONS를 permitAll로 허용해야 한다.

정리

스프링 시큐리티는 기본적으로 필터 체인 기반으로 동작하며, Spring WebFlux 환경에서는 WebFilterChainProxy(우선순위 -100)가 요청과 일치하는 SecurityWebFilterChain을 실행한다. SecurityWebFilterChain 빈을 직접 정의하면 스프링 부트의 기본 자동 구성 체인이 적용되지 않으므로, 체인 내에 인증 방식과 authorizeExchange 기반 인가 규칙을 선언하지 않으면 보안 헤더 설정 외에는 요청을 차단하지 않는다.

인증 흐름은 ServerAuthenticationConverter로 자격 증명을 추출하고 ReactiveAuthenticationManager로 유효성을 검증한 뒤 그 결과를 SecurityContext에 저장하는 방식으로 이루어진다. 일반 WebFilter로도 헤더를 직접 검사해 요청을 차단할 수는 있지만, 이 경우 SecurityContext가 비어 있어 스프링 시큐리티의 인가 기능이나 @AuthenticationPrincipal 같은 기능을 활용할 수 없다. 또한 무상태 API 환경에서는 CSRF 비활성화와 세션 저장을 방지하는 NoOpServerSecurityContextRepository를 함께 사용하며, 미인증 응답에 브라우저 로그인 창을 유발하는 WWW-Authenticate: Basic 헤더가 붙지 않도록 인증 진입점(AuthenticationEntryPoint)과 실패 핸들러를 명시적으로 지정해야 한다.

사용자를 식별해야 한다면 클라이언트가 Firebase ID 토큰을 Authorization: Bearer 헤더로 보내고, 서버가 ServerSecurityContextRepository의 load에서 verifyIdToken으로 검증한 결과를 SecurityContext로 반환하는 구성이 가능하다. 이 구성에서 검증에 실패한 요청은 인증 정보가 없는 요청이 되어 인가 규칙이 거부하거나 permitAll() 경로에서는 익명으로 통과하며, 저장소가 검증 호출 없이 인증 객체를 만들면 임의의 토큰 문자열로도 인증이 필요한 경로가 통과하므로 검증 호출이 빠지지 않았는지 시험으로 확인해야 한다. Firebase ID 토큰은 JWT이므로 Admin SDK 없이 스프링 시큐리티의 oauth2ResourceServer { jwt { } }로도 검증할 수 있으며, 이때는 구글의 JWK Set 주소와 iss, aud 검증기를 직접 설정해야 하고 토큰 철회 여부는 확인하지 못한다.

인가 규칙은 authorizeExchange를 통해 선언하며, anyExchange().authenticated()를 기본으로 설정하고 필요한 경로만 permitAll()로 예외 허용하는 방식이 안전하다. 아울러 브라우저의 CORS 프리플라이트 요청은 자격 증명이 실리지 않으므로 인증 및 인가보다 앞서 처리되어야 하며, 보안 체인에 .cors { }를 선언하고 CorsConfigurationSource 빈을 등록하여 체인 앞단에서 프리플라이트를 통과시키도록 구성해야 한다.

참고