Published on

Spring Boot 3 JWT 인증 401 반복? 필터체인 점검

Authors

서버 로그에는 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은 보통 두 지점에서 만들어집니다.

  1. AuthenticationEntryPoint: 인증이 필요하지만 인증 정보가 없거나 실패했을 때 401 응답 생성
  2. 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가지입니다.

  • sessionCreationPolicySTATELESS
  • 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를 permit
  • cors 설정을 명시하고 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을 차단
  • 프론트에서 fetchcredentials만 설정하고 헤더를 빼먹음

가장 빠른 확인은 컨트롤러에서 헤더를 찍는 것이 아니라, 필터 최상단에서 헤더 존재 여부를 로그로 확인하는 것입니다.

@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에서는 SecurityContextHolderFilterSecurityContextRepository 조합으로 컨텍스트를 다룹니다. 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% 이상은 빠르게 해결됩니다.

  1. Security DEBUG 로그로 어떤 체인이 선택되는지 확인
  2. JWT 필터가 실제로 실행되는지 확인(필터 최상단 로그)
  3. Authorization 헤더가 서버에 도착하는지 확인
  4. 필터 순서가 UsernamePasswordAuthenticationFilter보다 앞인지 확인
  5. OPTIONS 프리플라이트가 permit인지 확인(CORS 포함)
  6. permitAll 경로에서 필터가 401을 만들지 않는지 확인(shouldNotFilter)
  7. 검증 실패 예외가 삼켜지지 않고 최소한의 로그가 남는지 확인
  8. 인증 성공 시 SecurityContextAuthentication이 들어가는지 확인
  9. 권한 매핑이 올바른지 확인(401과 403 분리)

운영 장애가 “반복” 형태로 보일 때는 애플리케이션 외부 요인도 함께 의심해야 합니다. 서비스가 재시작 루프를 돌거나 프록시 설정이 꼬이면 증상이 비슷하게 보이기도 하니, 필요하면 이 글도 진단에 도움이 됩니다: systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘

마무리

Spring Boot 3에서 JWT 401이 반복될 때는 토큰 서명/만료 같은 “JWT 자체”보다, 필터체인에서 인증 정보를 언제/어떻게 세팅하고, 어떤 요청을 예외로 둘지가 더 자주 원인입니다.

위의 기준 골격 코드에 맞춰 필터 순서, 체인 매칭, CORS 프리플라이트, 헤더 전달, 예외 처리 흐름을 하나씩 고치면 401 루프는 대부분 끊깁니다. 그래도 해결이 안 되면, 실제 요청이 서버에 도착하는 형태(프록시/로드밸런서/게이트웨이)를 패킷 캡처나 액세스 로그로 함께 확인하는 쪽이 다음 단계입니다.