Published on

Spring Security 6 JWT 403 원인 - 권한매핑·CORS

Authors

서버 로그에 Authenticated=true가 보이는데도 API가 403으로 막히면, 문제는 대개 “인증(Authentication)”이 아니라 “인가(Authorization)” 또는 “브라우저 단 CORS 흐름”에 있습니다. 특히 Spring Security 6(= Spring Boot 3)로 올라오면서 SecurityFilterChain DSL, JwtAuthenticationConverter, CORS 설정 위치가 바뀌어 기존 레시피를 그대로 가져오면 403이 더 자주 발생합니다.

이 글은 JWT 기반 리소스 서버에서 403이 나는 대표 케이스를 **권한 매핑(ROLE/authority)**과 CORS(특히 preflight) 두 갈래로 나눠, 빠르게 진단하고 확실히 고치는 방법을 정리합니다.

관련해서 401/필터 순서 이슈는 아래 글도 함께 보면 좋습니다.

1) 403의 의미부터 확실히 구분하기

Spring Security 관점에서 응답이 갈리는 기준은 다음과 같습니다.

  • 401: 인증 실패(토큰 없음, 서명/만료, Bearer 파싱 실패 등)로 AuthenticationEntryPoint가 처리
  • 403: 인증은 되었지만 접근 권한이 없음. 즉 AccessDeniedHandler가 처리

JWT 리소스 서버에서 403은 보통 다음 중 하나입니다.

  1. 토큰은 유효해서 Authentication은 만들어졌지만, GrantedAuthority가 비어 있거나 기대하는 값과 다름
  2. hasRole/hasAuthority를 잘못 사용해서 매칭이 안 됨
  3. CORS preflight OPTIONS 요청이 시큐리티에서 차단되어 브라우저가 실제 요청을 보내지 못함(개발자 도구에는 403/blocked로 보임)

2) 권한 매핑 문제: roles는 있는데 왜 403인가

2.1 hasRole("ADMIN") vs hasAuthority("ROLE_ADMIN")

Spring Security의 기본 규칙은 다음입니다.

  • hasRole("ADMIN") 는 내부적으로 ROLE_ADMIN 을 찾습니다.
  • hasAuthority("ROLE_ADMIN") 는 정확히 ROLE_ADMIN 문자열을 찾습니다.

따라서 토큰에서 뽑아낸 권한이 ADMIN 인데 서버에서 hasRole("ADMIN") 을 쓰면, 실제로는 ROLE_ADMIN 을 기대하므로 불일치로 403이 납니다.

반대로 토큰 권한이 이미 ROLE_ADMIN 인데 hasRole("ROLE_ADMIN") 을 쓰면 ROLE_ROLE_ADMIN 을 찾게 되어 역시 403이 납니다.

아래는 흔한 실수 패턴입니다.

.authorizeHttpRequests(auth -> auth
  .requestMatchers("/admin/**").hasRole("ROLE_ADMIN")
  .anyRequest().authenticated()
)

위 코드는 ROLE_ROLE_ADMIN 을 찾게 됩니다. 올바른 형태는 둘 중 하나로 통일합니다.

// 패턴 A: hasRole("ADMIN") 사용
.requestMatchers("/admin/**").hasRole("ADMIN")
// 패턴 B: hasAuthority("ROLE_ADMIN") 사용
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")

2.2 Spring Security 6에서 JWT 권한 클레임 기본값 함정

리소스 서버(oauth2ResourceServer().jwt())는 JWT를 인증으로 받아들이지만, 권한을 어디에서 읽을지는 별도 문제입니다.

기본 JwtGrantedAuthoritiesConverter는 관례적으로 다음 클레임을 기대합니다.

  • scope 또는 scp를 읽어서 SCOPE_xxx 형태의 authority를 만듦

즉 토큰에 roles 클레임이 있어도, 아무 설정이 없으면 authority가 비어 있을 수 있습니다. 이 경우 인증은 성공하지만 인가에서 403이 납니다.

예를 들어 토큰 payload가 아래처럼 생겼다고 가정해봅시다.

{
  "sub": "user-1",
  "roles": ["ADMIN", "USER"]
}

이걸 그대로 두면 GrantedAuthority가 비어 있는 상태로 authenticated()만 통과하고, hasRole 구간에서 403이 떨어질 수 있습니다.

2.3 해결: JwtAuthenticationConverter로 roles 매핑하기

roles 배열을 ROLE_ prefix로 매핑해 GrantedAuthority로 만들면 해결됩니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.web.SecurityFilterChain;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  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(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
      );

    return http.build();
  }

  @Bean
  JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(this::extractAuthorities);
    return converter;
  }

  private Collection<SimpleGrantedAuthority> extractAuthorities(Jwt jwt) {
    List<String> roles = jwt.getClaimAsStringList("roles");
    if (roles == null) roles = List.of();

    return roles.stream()
      .map(role -> role.startsWith("ROLE_") ? role : "ROLE_" + role)
      .map(SimpleGrantedAuthority::new)
      .collect(Collectors.toList());
  }
}

이제 roles=["ADMIN"] 이면 authority가 ROLE_ADMIN 으로 생성되어 hasRole("ADMIN") 과 매칭됩니다.

2.4 scope 기반을 쓰는 경우: SCOPE_와의 불일치

만약 인가를 아래처럼 작성했다면,

.requestMatchers("/reports/**").hasAuthority("SCOPE_reports.read")

토큰에 scopereports.read로 들어가야 합니다.

{
  "scope": "reports.read reports.write"
}

그런데 토큰은 roles만 있고 scope가 없다면, 인증은 되더라도 SCOPE_... 권한이 없어 403이 납니다. 이때는 둘 중 하나를 선택해야 합니다.

  • 토큰 발급 정책을 scope 중심으로 바꾸고 서버 인가도 SCOPE_로 통일
  • 서버에서 roles를 읽어 ROLE_로 매핑하고 인가도 hasRole 중심으로 통일

핵심은 “토큰의 클레임 구조”와 “서버의 인가 표현식”을 한 체계로 맞추는 것입니다.

3) CORS 문제: JWT가 맞는데도 브라우저만 403

3.1 증상: Postman은 되는데 프론트에서만 403

CORS 이슈는 다음 패턴으로 나타납니다.

  • Postman이나 curl에서는 정상(200/204)
  • 브라우저에서만 실패
  • 개발자 도구 Network 탭에 OPTIONS 요청이 먼저 보이고, 그게 403 또는 CORS error로 막힘

이건 실제 GET/POST가 날아가기 전에 브라우저가 보내는 preflight(사전 요청)에서 차단되는 경우가 많습니다.

특히 아래 조건이면 preflight가 발생합니다.

  • Authorization 헤더를 보냄(Bearer 토큰)
  • Content-Type: application/json 등 단순 요청이 아닌 헤더
  • PUT/DELETE

3.2 해결: CORS를 Spring Security 체인에 “명시적으로” 연결

Spring Boot에서 WebMvcConfigurer#addCorsMappings만 설정해두고, 시큐리티에서 CORS를 켜지 않으면 필터 체인에서 막힐 수 있습니다.

Spring Security 6에서는 보통 아래처럼 CorsConfigurationSource를 빈으로 만들고 http.cors(...)를 활성화하는 패턴이 안전합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
public class CorsConfig {

  @Bean
  CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // 운영에서는 정확한 Origin만 허용하세요.
    config.setAllowedOrigins(List.of("http://localhost:3000", "https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
    config.setExposedHeaders(List.of("Authorization"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
  }
}

그리고 시큐리티 설정에서 CORS를 켭니다.

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

  return http.build();
}

3.3 allowCredentials=true 일 때 * 오리진이 금지

아래 조합은 브라우저 정책상 허용되지 않습니다.

  • Access-Control-Allow-Credentials: true
  • Access-Control-Allow-Origin: *

Spring에서 setAllowedOrigins(List.of("*")) 로 해두고 setAllowCredentials(true) 를 켜면, 기대와 다르게 동작하거나 preflight가 실패합니다.

개발 편의로 전체 허용이 필요하다면 allowedOriginPatterns를 쓰되, 운영에서는 반드시 구체 오리진으로 좁히는 것을 권장합니다.

config.setAllowedOriginPatterns(List.of("http://localhost:*"));
config.setAllowCredentials(true);

3.4 preflight OPTIONS를 인가 규칙에서 허용해야 하는가

CORS 설정이 제대로 연결되어 있다면, 보통 OPTIONS를 별도로 permitAll() 하지 않아도 통과합니다. 하지만 다음과 같은 경우에는 OPTIONS가 403으로 떨어질 수 있습니다.

  • 커스텀 필터가 OPTIONS를 가로채서 인증/인가를 강제
  • 프록시/게이트웨이에서 OPTIONS를 다른 경로로 라우팅

문제 재현 중 빠른 확인용으로는 아래처럼 열어두고 원인을 좁힐 수 있습니다.

.authorizeHttpRequests(auth -> auth
  .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll()
  .anyRequest().authenticated()
)

단, 이건 “CORS 문제인지 확인하기 위한 진단용”으로 쓰고, 근본적으로는 CORS 필터 체인 연결과 허용 헤더/오리진을 바로잡는 것이 정석입니다.

4) 403 진단 체크리스트 (로그로 빠르게 판별)

4.1 Security 디버그 로그 켜기

로컬에서만 아래 설정으로 필터 체인과 인가 판단을 추적하면 원인 분리가 빨라집니다.

logging:
  level:
    org.springframework.security: DEBUG

여기서 확인할 포인트는 다음입니다.

  • JwtAuthenticationToken 이 만들어졌는지
  • Granted Authorities 가 무엇으로 찍히는지
  • 어떤 AuthorizationManager 또는 AccessDecision에서 거절됐는지

4.2 권한이 비어 있으면 1순위는 매핑

로그에 authority가 [] 또는 SCOPE_...만 있고 ROLE_...이 없다면,

  • 토큰 클레임(roles, authorities, scope) 구조 확인
  • JwtAuthenticationConverter 설정 확인
  • hasRole/hasAuthority 표현식 통일

4.3 브라우저에서만 실패하면 1순위는 preflight

Network 탭에서 실제 요청보다 OPTIONS가 먼저 실패한다면,

  • http.cors(cors -> {}) 활성화 여부
  • 허용 오리진/헤더/메서드에 Authorization, Content-Type, OPTIONS 포함 여부
  • allowCredentials=true 와 오리진 와일드카드 충돌 여부

5) 권한매핑과 CORS를 함께 올바르게 구성한 예시

마지막으로 “JWT roles 매핑”과 “CORS”를 함께 넣은 최소 구성을 정리합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;
import java.util.stream.Collectors;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .cors(cors -> {})
      .csrf(csrf -> csrf.disable())
      .authorizeHttpRequests(auth -> auth
        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
        .requestMatchers("/public/**").permitAll()
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
      )
      .oauth2ResourceServer(oauth2 -> oauth2
        .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
      );

    return http.build();
  }

  @Bean
  JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(this::rolesToAuthorities);
    return converter;
  }

  private List<SimpleGrantedAuthority> rolesToAuthorities(Jwt jwt) {
    List<String> roles = jwt.getClaimAsStringList("roles");
    if (roles == null) roles = List.of();

    return roles.stream()
      .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r)
      .map(SimpleGrantedAuthority::new)
      .collect(Collectors.toList());
  }

  @Bean
  CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:3000", "https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
  }
}

6) 마무리: 403을 “권한”과 “CORS”로 먼저 분리하자

JWT 403 트러블슈팅은 복잡해 보이지만, 실제로는 다음 두 질문으로 빠르게 수렴합니다.

  1. 서버 로그에서 GrantedAuthority가 기대값(예: ROLE_ADMIN 또는 SCOPE_x)으로 들어오고 있는가
  2. 브라우저에서 preflight OPTIONS가 정상 통과하는가

이 두 축을 먼저 고정하면, 나머지 403은 대부분 인가 규칙의 경로 매칭(requestMatchers)이나 게이트웨이/프록시 설정 같은 “외부 요인”으로 범위를 좁힐 수 있습니다.