- Published on
Spring Security 6 JWT 401/403 원인 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 JWT를 붙이고 나면 가장 흔한 장애가 401 Unauthorized와 403 Forbidden입니다. 둘 다 “접근이 안 된다”로 보이지만 의미와 원인이 다릅니다.
- 401: 인증(Authentication) 실패. 토큰이 없거나, 형식이 틀렸거나, 검증에 실패했거나, 인증 객체가 SecurityContext에 세팅되지 않은 경우가 대부분입니다.
- 403: 인증은 됐지만 인가(Authorization) 실패. 권한/역할이 부족하거나, CSRF/CORS 같은 정책 때문에 막히는 경우가 많습니다.
이 글은 Spring Security 6(= Spring Boot 3 계열)에서 JWT를 쓸 때 401/403을 만드는 대표 원인 9가지를 증상 → 진단 포인트 → 해결 순서로 압축합니다.
먼저: 401 vs 403 빠른 판별법
1) 어디서 떨어지는지 확인
- 401은 보통
AuthenticationEntryPoint가 응답을 만들고 - 403은 보통
AccessDeniedHandler가 응답을 만듭니다.
다음처럼 핸들러를 명시하면 “누가 응답했는지”가 로그/응답에서 분명해집니다.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.exceptionHandling(e -> e
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"unauthorized\"}");
})
.accessDeniedHandler((req, res, ex) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"forbidden\"}");
})
)
.build();
}
2) 디버그 로그 켜기
개발/스테이징에서만 다음을 켜면 필터 체인에서 어디서 막히는지 빨리 보입니다.
logging.level.org.springframework.security=DEBUG
원인 1) Authorization 헤더 포맷/Prefix 불일치 (401)
증상
- 토큰을 보냈다고 생각했는데 계속 401
- Postman에서는 되는데 프론트에서만 401
진단 포인트
- 서버 코드가
Bearerprefix를 강제하는데, 클라이언트가JWT또는 그냥 토큰만 보내는 경우 - 혹은 헤더 이름이
Authorization이 아니라Authentication등으로 잘못됨
해결
필터에서 엄격하게 파싱하되, 에러 로그를 남겨 원인을 즉시 드러내세요.
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
// 여기서 조용히 넘어가면 나중에 401만 보이고 원인이 숨습니다.
logger.debug("Missing or invalid Authorization header: {}", auth);
filterChain.doFilter(request, response);
return;
}
String token = auth.substring(7);
원인 2) 필터 체인 순서/등록 실수로 JWT 필터가 안 탐 (401)
증상
- 토큰이 정상인데도
SecurityContext가 비어 있음 UsernamePasswordAuthenticationFilter이전/이후 순서가 꼬여 인증이 세팅되지 않음
진단 포인트
addFilterBefore/After위치 확인SecurityFilterChain이 여러 개일 때 특정 경로가 다른 체인을 타는지 확인
해결
JWT 필터는 보통 UsernamePasswordAuthenticationFilter 이전에 둡니다.
http
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
체인이 여러 개인 경우 securityMatcher로 경로를 분리했다면, JWT가 필요한 경로가 올바른 체인에 포함되는지도 점검하세요.
원인 3) Stateless 설정 누락으로 세션/컨텍스트 기대가 어긋남 (401/403)
증상
- 어떤 요청은 되고 어떤 요청은 안 됨
- 리다이렉트/세션 기반처럼 동작하거나, 익명 사용자로 평가됨
진단 포인트
- JWT 기반이면 기본적으로 세션을 쓰지 않는 게 일반적
SessionCreationPolicy미설정 시 예상치 못한 동작이 섞일 수 있음
해결
http
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
원인 4) 토큰 서명키/알고리즘 불일치 (401)
증상
- 로그인에서 발급한 토큰을 바로 넣어도 401
- 서버 로그에
SignatureException,Invalid signature,Mac verification failed등
진단 포인트
- HS256인데 검증은 RS256 공개키로 한다거나(반대도 마찬가지)
- 환경별로 시크릿이 다르게 주입됨(로컬/스테이징/프로덕션)
해결
발급/검증 컴포넌트가 동일한 알고리즘/키를 쓰는지 확인하고, 키 로딩을 한 곳으로 모읍니다.
@Bean
JwtDecoder jwtDecoder(@Value("${jwt.secret}") String secret) {
SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
return NimbusJwtDecoder.withSecretKey(key).build();
}
운영에서 특히 “환경변수 주입 실패 → 기본값 사용 → 검증 실패” 패턴이 잦습니다. (인프라 설정이 얽혀 있을 때는 권한/토큰 이슈가 다른 시스템에서도 비슷하게 발생합니다. 예: EKS Pod에서 STS 403 InvalidIdentityToken 해결)
원인 5) exp/nbf/iat 시간 오차(Clock Skew)로 만료 처리 (401)
증상
- 어떤 서버에서는 되고 어떤 서버에서는 안 됨(멀티 노드)
- 토큰 발급 직후인데도
JwtValidationException: An error occurred while attempting to decode the Jwt혹은 expired
진단 포인트
- 서버 시간(NTP) 불일치
- 모바일/클라이언트 시간이 크게 틀어진 경우(특히 nbf 사용 시)
해결
- 서버는 NTP 동기화
- 검증 시 clock skew를 약간 허용
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
OAuth2TokenValidator<Jwt> withSkew = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://issuer.example"),
new JwtTimestampValidator(Duration.ofSeconds(60))
);
decoder.setJwtValidator(withSkew);
return decoder;
}
원인 6) issuer/audience 검증 실패 (401)
증상
- 서명은 맞는데도 401
- 로그에
The iss claim is not valid,aud claim is not valid류
진단 포인트
- 토큰의
iss,aud가 서버 설정과 다름 - 여러 클라이언트(웹/앱/파트너)에서 토큰을 섞어 쓰는 경우
해결
발급 정책과 검증 정책을 맞추고, 필요한 경우 서비스별 audience를 분리합니다.
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer("https://auth.mycorp.com");
// aud 검증을 추가로 구현할 수도 있습니다.
원인 7) 권한 매핑(roles/authorities) 실수로 인가 실패 (403)
증상
- 인증은 된 것 같은데 특정 API에서만 403
@PreAuthorize("hasRole('ADMIN')")가 항상 실패
진단 포인트
- Spring Security에서
hasRole('ADMIN')은 내부적으로ROLE_ADMIN을 기대 - JWT claim에
roles: ["ADMIN"]만 넣고GrantedAuthority로 변환하지 않으면 403
해결
JWT의 claim을 GrantedAuthority로 변환하는 로직을 명시하세요.
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter gac = new JwtGrantedAuthoritiesConverter();
gac.setAuthoritiesClaimName("roles");
gac.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(gac);
return converter;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
.build();
}
그리고 메서드 보안을 쓴다면 활성화도 확인합니다.
@EnableMethodSecurity
@Configuration
class MethodSecurityConfig {}
원인 8) CORS 프리플라이트(OPTIONS) 차단 (401/403처럼 보임)
증상
- 브라우저에서만 실패, Postman/curl은 성공
- 네트워크 탭에 실제 요청 전에
OPTIONS가 401/403
진단 포인트
- CORS 설정이 없거나,
OPTIONS를 인증 대상으로 처리 - 프리플라이트는 Authorization 헤더 없이 날아오는 경우가 많음
해결
CORS를 명시하고, 프리플라이트를 허용합니다.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
)
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("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;
}
원인 9) CSRF가 켜진 상태에서 상태변경 요청이 차단 (403)
증상
- GET은 되는데 POST/PUT/DELETE만 403
- 로그에
Invalid CSRF token found등
진단 포인트
- JWT를 쿠키로 들고가거나, 브라우저 기반 호출인데 CSRF 토큰을 안 보냄
- “우리는 Authorization 헤더로 JWT 보내는데 왜 CSRF?”: Spring Security 기본값은 브라우저 시나리오를 염두에 둡니다.
해결
API 서버가 순수 Stateless + Bearer JWT라면 보통 CSRF를 끕니다.
http.csrf(csrf -> csrf.disable());
반대로 JWT를 쿠키에 담는 구조(특히 SameSite=None 쿠키)라면 CSRF를 끄기보다 CSRF 토큰을 함께 설계하는 쪽이 안전합니다.
장애 재현/진단을 빠르게 하는 체크리스트
요청 단에서 확인
Authorization: Bearer <token>정확한지- 브라우저라면 프리플라이트
OPTIONS가 실패하는지 - 토큰이 실제로 최신인지(만료/재발급)
서버 단에서 확인
- 필터가 실행되는지(디버그 로그)
SecurityContextHolder.getContext().getAuthentication()값- 401이면 EntryPoint, 403이면 AccessDeniedHandler가 호출되는지
운영 환경에서 흔한 “설정 꼬임” 패턴
- 환경변수/시크릿 주입 실패 → 서명키 불일치
- 게이트웨이/프록시가
Authorization헤더를 제거 - 배포 파이프라인의 권한 문제로 설정이 누락
권한/토큰 문제는 인프라에서도 비슷한 형태로 나타납니다. 예를 들어 OIDC 기반 배포에서 AssumeRole이 실패하는 케이스는 GitHub Actions OIDC로 AWS AssumeRole 실패 해결처럼 “토큰 클레임/신뢰 정책 불일치”가 핵심 원인이 됩니다.
마무리: 401/403을 “원인까지” 응답하게 만들기
JWT 401/403을 오래 끌게 만드는 가장 큰 이유는, 서버가 단순히 상태코드만 던지고 왜 실패했는지를 숨기기 때문입니다. 개발/스테이징에서는 아래 2가지만 해도 평균 진단 시간이 크게 줄어듭니다.
AuthenticationEntryPoint/AccessDeniedHandler를 커스터마이즈해서 원인을 분리org.springframework.security=DEBUG로 필터 체인 흐름을 확인
이후에는 본문 9가지 원인을 위에서부터 체크하면 대부분은 10~30분 안에 결론이 납니다.