- Published on
Spring Boot 3 JWT 인증 401 반복? 필터체인 점검
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에는 401 Unauthorized만 반복되고, 클라이언트는 토큰을 보내고 있다고 주장합니다. Spring Boot 3(= Spring Security 6)로 넘어오면서 보안 설정 DSL과 기본 동작이 바뀌었고, JWT 인증은 대부분 커스텀 필터로 구현하다 보니 필터체인 순서/예외 처리/컨텍스트 저장 방식에서 작은 실수가 곧바로 401 루프로 이어집니다.
이 글은 “토큰이 틀렸나?”를 넘어, 필터체인 관점에서 401 반복을 끊는 점검 루틴을 제공합니다. 특히 다음 상황을 집중적으로 다룹니다.
- Authorization 헤더가 있는데도 매번 401
- 특정 엔드포인트만 401
- 로그인이 성공한 직후 다음 요청부터 401
- CORS 프리플라이트(OPTIONS)부터 401
관련해서 보안/인증 문제는 프록시나 리다이렉트가 개입하면 더 복잡해집니다. OAuth 콜백이 302로 무한 루프를 도는 케이스도 결국 “요청이 기대한 형태로 서버에 도착하지 않는다”는 점에서 유사하니, 필요하면 이 글도 함께 참고하세요: Nginx 뒤 OAuth 콜백 302 무한리다이렉트 원인
1) 먼저 확인: 401을 누가, 어디서 만들고 있나
Spring Security에서 401은 보통 두 지점에서 만들어집니다.
- AuthenticationEntryPoint: 인증이 필요하지만 인증 정보가 없거나 실패했을 때 401 응답 생성
- AccessDeniedHandler: 인증은 되었지만 권한이 없을 때 403 응답 생성
즉, “401 반복”이라면 대개 다음 둘 중 하나입니다.
- JWT 필터가 인증 정보를
SecurityContext에 넣지 못했거나 - 넣었는데도 다음 필터/설정이 이를 무시하거나 지워버림
가장 먼저 할 일은 Security 디버그 로그로 “필터가 실행되는지/어떤 예외가 나는지”를 확인하는 것입니다.
# application.yml
logging:
level:
org.springframework.security: DEBUG
운영에서는 DEBUG가 부담이므로, 재현 환경에서만 켜고 원인을 잡은 뒤 끄는 것을 권장합니다.
2) Spring Boot 3 JWT 기본 골격(정상 동작 기준)
아래는 Spring Security 6에서 흔히 쓰는 “Stateless JWT” 설정의 기준점입니다.
핵심 포인트는 4가지입니다.
sessionCreationPolicy를STATELESS로csrf는 API라면 보통 disable(브라우저 폼 로그인과 다름)- 커스텀 JWT 필터를
UsernamePasswordAuthenticationFilter보다 앞에 배치 - 인증 실패 시 401을 명확히 반환하는
AuthenticationEntryPoint설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(eh -> eh
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"unauthorized\"}");
})
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
JWT 필터는 OncePerRequestFilter 기반이 가장 흔합니다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring("Bearer ".length());
try {
Authentication authentication = jwtTokenService.toAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException ex) {
// 여기서 바로 401을 내려도 되지만, 공통 EntryPoint로 위임하는 방식도 많습니다.
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
이 “정상 골격”과 비교하면서 아래 체크리스트를 적용하면 원인 범위를 빠르게 줄일 수 있습니다.
3) 401 반복의 1순위: 필터 순서가 뒤에 붙어 인증이 늦게 됨
가장 흔한 실수는 JWT 필터를 너무 뒤에 붙이는 것입니다.
addFilterAfter로 잘못 추가addFilterAt위치가 부정확- 여러
SecurityFilterChain이 있을 때, JWT 필터가 없는 체인에 매칭됨
권장 패턴은 보통 다음 중 하나입니다.
addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)- 또는
BearerTokenAuthenticationFilter를 쓰는 리소스 서버 방식으로 전환
특히 “특정 URL만 401”이면, 필터 순서보다 체인 매칭 문제일 가능성이 큽니다.
여러 SecurityFilterChain이 있을 때 체인 매칭 점검
예를 들어 /api/**와 /admin/**를 체인으로 분리했다면, JWT 필터가 /api/** 체인에만 들어가 있을 수 있습니다.
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.build();
}
@Bean
@Order(2)
SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a.anyRequest().permitAll())
.build();
}
이때 /api/**가 아닌 경로로 요청이 들어가면 JWT 필터가 아예 실행되지 않습니다. 디버그 로그에서 “어떤 체인이 선택됐는지”를 꼭 확인하세요.
4) 401 반복의 2순위: CORS 프리플라이트(OPTIONS)가 막힘
프론트엔드가 브라우저라면, 실제 GET/POST 전에 OPTIONS 프리플라이트가 먼저 나갑니다. 이 OPTIONS가 인증을 요구받아 401이 나면, 브라우저는 본 요청을 보내지 못하고 “계속 실패”처럼 보입니다.
해결책은 보통 2가지입니다.
OPTIONS를 permitcors설정을 명시하고CorsConfigurationSource를 등록
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> {})
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.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;
}
프록시(Nginx, ALB) 뒤에 있을 때는 CORS 헤더가 중간에서 누락되거나, Authorization 헤더가 업스트림으로 전달되지 않는 설정 이슈도 자주 섞입니다.
5) 401 반복의 3순위: Authorization 헤더가 서버에 도착하지 않음
서버 코드가 아니라 “요청이 변형”되는 케이스입니다.
- Nginx가
Authorization헤더를 제거/미전달 - CDN/WAF 정책이
Authorization을 차단 - 프론트에서
fetch에credentials만 설정하고 헤더를 빼먹음
가장 빠른 확인은 컨트롤러에서 헤더를 찍는 것이 아니라, 필터 최상단에서 헤더 존재 여부를 로그로 확인하는 것입니다.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
logger.debug("Authorization header present: {}", authHeader != null);
filterChain.doFilter(request, response);
}
운영에서 토큰 값 자체를 로깅하는 것은 위험합니다. “존재 여부”까지만 찍는 습관이 안전합니다.
6) 401 반복의 4순위: JWT 필터가 예외를 삼켜서 인증이 비어 있음
위의 예시 필터처럼 JwtException을 catch 하고 clearContext만 한 뒤 계속 진행하면, 결국 뒤에서 authenticated() 조건을 만족하지 못해 EntryPoint가 401을 반환합니다.
이 자체는 “정상”일 수 있지만, 문제는 원인이 로그에 남지 않아 디버깅이 어려워지는 점입니다.
권장 방식 중 하나는 “토큰이 있었는데 검증 실패”를 최소한의 정보로 남기는 것입니다.
try {
Authentication authentication = jwtTokenService.toAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException ex) {
logger.info("JWT validation failed: {}", ex.getMessage());
SecurityContextHolder.clearContext();
}
또 다른 방식은 검증 실패 시 즉시 401을 내려 “실패 지점”을 명확히 하는 것입니다.
catch (JwtException ex) {
SecurityContextHolder.clearContext();
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"invalid token\"}");
return;
}
팀/제품 성격에 따라 어느 쪽이 맞는지 선택하면 됩니다. 중요한 건 “401이 어디서 만들어졌는지”가 추적 가능해야 한다는 점입니다.
7) 401 반복의 5순위: SecurityContext 저장 방식 오해(Stateless에서 특히)
Spring Security 6에서는 SecurityContextHolderFilter와 SecurityContextRepository 조합으로 컨텍스트를 다룹니다. Stateless JWT에서는 보통 “요청마다 토큰을 검증하고 컨텍스트를 세팅”해야 하며, 세션에 저장되는 것을 기대하면 안 됩니다.
다음과 같은 착각이 401 반복을 만듭니다.
- 로그인 API에서
SecurityContext에 넣었으니 다음 요청에도 유지될 거라고 기대 - 그런데
STATELESS라 세션에 저장되지 않아 다음 요청은 다시 익명
정답은 “모든 보호 API 요청마다” Authorization 헤더로 JWT를 보내야 합니다.
프론트에서 토큰 저장/전송이 불안정하면, 서버에서는 “가끔 401”처럼 보일 수 있습니다.
8) 401 반복의 6순위: permitAll 경로인데도 필터가 막아버림
permitAll은 “인가(Authorization)”를 풀어주는 것이지, 여러분의 JWT 필터가 임의로 401을 내려버리면 소용이 없습니다.
예를 들어 /api/auth/login은 토큰이 없어야 정상인데, 필터가 “토큰 없으면 401”을 내려버리면 로그인 자체가 불가능해집니다.
해결은 shouldNotFilter로 인증 제외 경로를 명확히 하는 것입니다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/api/auth/")
|| path.startsWith("/actuator/");
}
이 패턴은 특히 “로그인/회원가입만 401”에서 즉효입니다.
9) 401 반복의 7순위: Role/Authority 매핑 실수로 403이 아니라 401처럼 보임
원칙적으로 권한 부족은 403입니다. 그런데 다음 설정 실수로 401처럼 관측되는 경우가 있습니다.
- 인증 객체 생성 시
GrantedAuthority가 비어 있어 접근이 막히고, 커스텀 EntryPoint가 401로 내려버림 - 컨트롤러에서
@PreAuthorize가 실패했는데 예외 핸들링이 섞여 401로 변환
권한 매핑은 다음처럼 명시적으로 하세요.
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
Authentication auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
그리고 exceptionHandling에서 401과 403을 분리해두면 관측이 쉬워집니다.
.exceptionHandling(eh -> eh
.authenticationEntryPoint((req, res, ex) -> res.sendError(401))
.accessDeniedHandler((req, res, ex) -> res.sendError(403))
)
10) 권장 대안: 커스텀 필터 대신 Resource Server JWT로 단순화
가능하다면 Spring Security의 OAuth2 Resource Server 기능을 쓰는 것이 “필터체인 실수”를 크게 줄입니다. (JWT를 직접 파싱/검증하기보다 표준 구성요소에 맡김)
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.build();
}
이 방식은 토큰 검증 실패 시 동작도 예측 가능하고, 표준 로그/예외 흐름을 따르기 때문에 401 반복 디버깅이 쉬워집니다.
11) 실전 점검 체크리스트(위에서 아래로)
아래 순서대로 보면 “401 반복”의 80% 이상은 빠르게 해결됩니다.
- Security DEBUG 로그로 어떤 체인이 선택되는지 확인
- JWT 필터가 실제로 실행되는지 확인(필터 최상단 로그)
Authorization헤더가 서버에 도착하는지 확인- 필터 순서가
UsernamePasswordAuthenticationFilter보다 앞인지 확인 OPTIONS프리플라이트가 permit인지 확인(CORS 포함)permitAll경로에서 필터가 401을 만들지 않는지 확인(shouldNotFilter)- 검증 실패 예외가 삼켜지지 않고 최소한의 로그가 남는지 확인
- 인증 성공 시
SecurityContext에Authentication이 들어가는지 확인 - 권한 매핑이 올바른지 확인(401과 403 분리)
운영 장애가 “반복” 형태로 보일 때는 애플리케이션 외부 요인도 함께 의심해야 합니다. 서비스가 재시작 루프를 돌거나 프록시 설정이 꼬이면 증상이 비슷하게 보이기도 하니, 필요하면 이 글도 진단에 도움이 됩니다: systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘
마무리
Spring Boot 3에서 JWT 401이 반복될 때는 토큰 서명/만료 같은 “JWT 자체”보다, 필터체인에서 인증 정보를 언제/어떻게 세팅하고, 어떤 요청을 예외로 둘지가 더 자주 원인입니다.
위의 기준 골격 코드에 맞춰 필터 순서, 체인 매칭, CORS 프리플라이트, 헤더 전달, 예외 처리 흐름을 하나씩 고치면 401 루프는 대부분 끊깁니다. 그래도 해결이 안 되면, 실제 요청이 서버에 도착하는 형태(프록시/로드밸런서/게이트웨이)를 패킷 캡처나 액세스 로그로 함께 확인하는 쪽이 다음 단계입니다.