Spring

[Spring Security] JWT 인증 성공 로그가 있는데 관리자 API가 401이었던 이유

디버거러너 2026. 9. 4. 13:27

배경

Spring Security로 JWT 인증·인가 구조를 전환한 뒤, USER 권한 토큰으로 관리자 API를 호출했다.

기대한 결과는 403 Forbidden이었다.

인증 성공
하지만 ADMIN 권한 없음
→ 403 Forbidden

그런데 실제 응답은 401 Unauthorized였다. 더 혼란스러웠던 점은 애플리케이션 로그에 JWT 인증 성공까지 남았다는 것이다.

JwtFilter 진입 - uri=/admin/users/1
JWT 인증 성공 - userId=1, role=USER

이 글은 “토큰 검증은 성공했는데 왜 최종 응답은 401이었는가”를 추적하며, JWT 필터와 Spring Security 인가 정책의 책임을 다시 나눈 기록이다.

먼저 구분할 것: 401과 403은 실패 지점이 다르다

관리자 API의 응답은 다음 표처럼 해석해야 한다.

요청 상태 의미 기대 응답

토큰 없음 인증 주체가 없음 401 Unauthorized
토큰이 잘못됐거나 만료됨 JWT 인증 실패 401 Unauthorized
USER 토큰으로 관리자 API 접근 인증은 성공했지만 권한 부족 403 Forbidden
ADMIN 토큰으로 관리자 API 접근 인증·인가 성공 정상 응답

즉 위 로그처럼 role=USER까지 복원됐다면 JWT 파싱이 실패한 것이 아니다. 먼저 “권한 부족 403이 다른 dispatch에서 다시 처리됐는가”를 의심해야 했다.

원래 구조: JwtFilter가 너무 많은 일을 했다

전환 전 JwtFilter는 토큰을 검증하고 request attribute에 사용자 정보를 저장했다. /admin URL인지 확인하고 관리자 권한도 직접 검사했다. Controller의 @Auth AuthUser는 별도 ArgumentResolver가 request attribute를 읽어 만들었다.

JwtFilter
→ JWT 검증
→ request.setAttribute(...)
→ /admin URL과 ADMIN 권한 직접 검사
→ @Auth ArgumentResolver
→ Controller

이 구조에서는 인증에 성공했는지, URL 접근이 허용됐는지, Controller가 어떤 인증 정보를 받는지가 하나의 필터와 Servlet request attribute에 섞인다.

1. JWT 필터는 Authentication 생성만 하도록 바꿨다

JWT Claims를 AuthUser로 바꾸고, 역할을 Spring Security authority 형식으로 변환해 SecurityContext에 저장했다.

Claims claims = jwtUtil.extractClaims(token);
AuthUser authUser = createAuthUser(claims);

Authentication authentication = new UsernamePasswordAuthenticationToken(
        authUser,
        null,
        List.of(new SimpleGrantedAuthority("ROLE_" + authUser.getUserRole().name()))
);

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

Spring Security는 SecurityContext에 현재 사용자의 Authentication을 보관하고, 인가 단계에서 principal과 authorities를 사용한다. 이때 hasRole("ADMIN")은 ROLE_ADMIN authority를 기준으로 판단한다. 

따라서 필터의 분기는 두 가지다.

  • JWT 서명·만료·형식·Claim이 잘못돼 인증 정보를 만들 수 없는 경우: 401
  • JWT가 없거나 Bearer 형식이 아닌 경우: 아무 응답도 만들지 않고 다음 필터로 전달

특히 sendError() 뒤에는 반드시 return해야 한다.

} catch (ExpiredJwtException e) {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
    return;
}

filterChain.doFilter(request, response);

sendError()는 오류 응답 생성을 컨테이너에 맡기고, 호출 뒤 response를 더 작성하지 않아야 하는 상태로 만든다. 따라서 JWT 오류를 응답한 뒤에는 return해 현재 필터의 실행을 끝낸다. 

Bearer prefix 처리는 sendError()와는 별개의 책임이다. Authorization 헤더와 Bearer 접두사는 HTTP 요청 형식이므로 이를 읽는 JwtFilter가 해석한다. 반면 JwtUtil은 HTTP와 무관하게 순수 JWT 문자열의 생성·검증·Claims 파싱만 담당하도록 두었다.

private static final String BEARER_PREFIX = "Bearer ";

private String resolveToken(HttpServletRequest request) {
    String authorizationHeader = request.getHeader("Authorization");
    if (!StringUtils.hasText(authorizationHeader)
            || !authorizationHeader.startsWith(BEARER_PREFIX)) {
        return null;
    }
    return authorizationHeader.substring(BEARER_PREFIX.length());
}

2. URL별 권한 판단은 SecurityConfig로 옮겼다

JwtFilter가 /admin 문자열을 검사하는 대신 SecurityFilterChain에 정책을 선언했다.

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .requestMatchers("/auth/**").permitAll()
        .requestMatchers(HttpMethod.GET, "/todos", "/todos/**", "/users/**").permitAll()
        .anyRequest().authenticated()
)
.exceptionHandling(exception -> exception
        .authenticationEntryPoint((request, response, e) ->
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증이 필요합니다."))
        .accessDeniedHandler((request, response, e) ->
                response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."))
)

이제 USER 토큰은 ROLE_USER authority를 가진 Authentication으로 SecurityContext에 들어간다. /admin/** 규칙은 ROLE_ADMIN을 요구하므로 Spring Security가 권한 부족으로 판단하고 AccessDeniedHandler가 403을 반환한다.

Spring Security의 authorizeHttpRequests는 요청 경로와 권한 규칙을 연결하며, permitAll, authenticated, hasRole처럼 정책을 선언할 수 있다.

3. 403이 401로 바뀐 지점은 ERROR dispatch였다

문제는 첫 요청만 인가되는 것이 아니라는 데 있었다. Spring Security의 AuthorizationFilter는 일반 요청뿐 아니라 FORWARD, ERROR, INCLUDE 같은 dispatch도 인가할 수 있다.

USER → PATCH /admin/users/1
     → JwtFilter: 인증 성공, ROLE_USER 저장
     → /admin/** 인가 실패
     → AccessDeniedHandler가 response.sendError(403, ...) 호출
     → 컨테이너의 오류 처리 경로로 ERROR dispatch
     → SecurityFilterChain과 URL 인가 규칙 재적용
     → 최종 응답이 기대와 다르게 처리될 수 있음

공식 문서도 ERROR dispatch가 다시 인가될 수 있으며, 오류 처리를 위해 ERROR dispatch를 허용하는 구성을 고려할 수 있다고 설명한다.

그래서 URL별 규칙보다 먼저 ERROR dispatch를 공개했다.

.authorizeHttpRequests(auth -> auth
        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
        .requestMatchers("/admin/**").hasRole("ADMIN")
        // ...
)

여기서 ERROR dispatch를 permitAll()로 둔 것은 /admin/** 자체를 공개한다는 뜻이 아니다. 최초 /admin/** 요청은 여전히 hasRole("ADMIN") 규칙으로 인가한다. 이미 403으로 결정된 뒤의 ERROR dispatch가 URL 인가 규칙에 의해 다시 차단되는 것을 막는 설정이다.

4. Controller의 @Auth 사용 방식은 유지했다

SecurityContext로 전환하면서 Controller마다 인증 정보를 꺼내는 코드를 새로 쓰지 않도록 @Auth를 @AuthenticationPrincipal의 메타 어노테이션으로 바꿨다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(errorOnInvalidType = true)
public @interface Auth {
}
public ResponseEntity<TodoSaveResponse> saveTodo(
        @Auth AuthUser authUser,
        @Valid @RequestBody TodoSaveRequest request
) {
    return ResponseEntity.ok(todoService.saveTodo(authUser, request));
}

Controller의 파라미터 형태는 유지하면서, 내부적으로는 request attribute가 아니라 Authentication.principal의 AuthUser를 받는다. 관리자 접근 AOP도 같은 이유로 request attribute 대신 SecurityContext에서 principal을 읽도록 변경했다.

확인한 결과

시나리오 JwtFilter 결과 최종 응답

토큰 없음 + 보호 API Authentication 미생성 401
잘못된·만료된 토큰 필터에서 JWT 예외 처리 401
USER 토큰 + /admin/** ROLE_USER Authentication 생성 403
ADMIN 토큰 + /admin/** ROLE_ADMIN Authentication 생성 성공

게시 전에는 USER 토큰 요청에서 JWT 인증 성공 - role=USER 로그와 403 응답이 함께 보이는 Swagger 또는 curl 캡처를 추가할 예정이다.

정리

이번 문제는 JWT 검증 실패가 아니라, 인증과 인가 그리고 ERROR dispatch의 책임이 섞여 최종 상태 코드까지 바뀐 경우였다.

JwtFilter
→ 토큰 검증과 Authentication 생성

SecurityConfig
→ 공개·인증·역할 기반 URL 정책

exceptionHandling
→ 인증 실패 401 / 권한 부족 403

ERROR dispatch 허용
→ ERROR dispatch가 URL 인가 규칙에 의해 다시 차단되는 것을 방지

JWT 인증 성공 로그가 남는다면 토큰 파싱만 다시 보지 않고, Authentication의 authority, URL 인가 규칙, ERROR dispatch 재진입까지 이어서 확인해야 한다.

참고 자료