- Published on
Spring Security 6 JWT 403 원인 - 권한매핑·CORS
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에 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은 보통 다음 중 하나입니다.
- 토큰은 유효해서
Authentication은 만들어졌지만,GrantedAuthority가 비어 있거나 기대하는 값과 다름 hasRole/hasAuthority를 잘못 사용해서 매칭이 안 됨- 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")
토큰에 scope가 reports.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: trueAccess-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 트러블슈팅은 복잡해 보이지만, 실제로는 다음 두 질문으로 빠르게 수렴합니다.
- 서버 로그에서
GrantedAuthority가 기대값(예:ROLE_ADMIN또는SCOPE_x)으로 들어오고 있는가 - 브라우저에서 preflight
OPTIONS가 정상 통과하는가
이 두 축을 먼저 고정하면, 나머지 403은 대부분 인가 규칙의 경로 매칭(requestMatchers)이나 게이트웨이/프록시 설정 같은 “외부 요인”으로 범위를 좁힐 수 있습니다.