Программирование

Как исправить 401 Unauthorized в Spring Boot JWT фильтре

Фильтр JWT в Spring Boot может вернуть 401, если он выполняется после AnonymousAuthenticationFilter или не устанавливает SecurityContext. Отключите анонимный доступ и упорядочьте фильтры.

Spring Boot Security: 401 Unauthorized при использовании JWT в собственном фильтре

У меня есть приложение Spring Boot, которое аутентифицирует пользователей через JWT. Я написал собственный AuthFilter, который берёт токен из заголовка запроса, валидирует его и ставит SecurityContext. Но даже при отправке запросов с корректным токеном я всё равно получаю ответ 401 Unauthorized. В логах видно, что AnonymousAuthenticationFilter ставит анонимный контекст, а затем вызывается DelegatingAuthenticationEntryPoint.

Пример фрагмента лога:

2025-12-02T11:50:54.636+05:30 DEBUG ... AnonymousAuthenticationFilter: Set SecurityContextHolder to anonymous SecurityContext
2025-12-02T11:50:54.638+05:30 DEBUG ... HttpSessionRequestCache: Saved request http://localhost:8083/error?continue
...

(Полный лог прилагается в виде скриншотов.)

Что я упустил в настройке фильтра или в конфигурации Spring Security, чтобы Spring Security воспринимал запрос как неавторизованный, хотя JWT действительно валиден?

Spring Boot Security – 401 Unauthorized с пользовательским JWT‑фильтром
Ошибка 401 генерируется DelegatingAuthenticationEntryPoint, потому что, когда цепочка доходит до FilterSecurityInterceptor, объект SecurityContext всё ещё пустой или содержит анонимный токен. Это происходит, если ваш собственный фильтр:

  1. Запускается слишком поздно – после AnonymousAuthenticationFilter.
  2. Не аутентифицирует действительно – просто логирует или ставит контекст, который потом перезаписывается.
  3. Не зарегистрирован для нужного паттерна URL – запрос просто обходится мимо него.

Ниже – полностью минимальная конфигурация, которая показывает правильный порядок, как строить Authentication и как отключить анонимный fallback.


1. Где должен находиться фильтр в цепочке

Фильтр Позиция Зачем это важно
SecurityContextPersistenceFilter 1 Загружает/создаёт SecurityContext.
Ваш JwtAuthFilter 2 Должен работать перед AnonymousAuthenticationFilter.
AnonymousAuthenticationFilter 3 Вставляет анонимный токен, если контекст всё ещё пустой.
UsernamePasswordAuthenticationFilter 4 Обрабатывает форму логина (не используется в JWT).
ExceptionTranslationFilter 5 Вызывает entry point, когда контекст остаётся неаутентифицированным.
FilterSecurityInterceptor 6 Авторизует запрос.

Если пользовательский фильтр размещён после AnonymousAuthenticationFilter, анонимный токен перекрывает всё, что вы там установили, и запрос всегда будет выглядеть неаутентифицированным.


2. Пример пользовательского фильтра

java
@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;          // ваш помощник по проверке токенов
    private final UserDetailsService userDetails; // опционально – для ролей

    public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetails) {
        this.jwtService = jwtService;
        this.userDetails = userDetails;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtService.isValid(token)) {
                String username = jwtService.getUsername(token);
                UserDetails user = userDetails.loadUserByUsername(username);

                // Создаём объект Authentication, который Spring Security сможет использовать
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                user, null, user.getAuthorities());

                // Положим его в SecurityContext
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // Продолжаем цепочку – контекст теперь либо аутентифицирован, либо пустой
        chain.doFilter(request, response);
    }
}

Ключевые моменты

Что Зачем
Наследует OncePerRequestFilter Гарантирует, что фильтр выполнится один раз на запрос и будет потокобезопасен.
Проверяет заголовок Authorization Обрабатывает только запросы, содержащие токен.
Валидирует токен до создания Authentication Предотвращает попадание в контекст недействительного/истёкшего токена.
Использует UserDetailsService (опционально) Загружает предоставленные роли для доступа по ролям.
Вызывает chain.doFilter() после установки контекста Позволяет последующим фильтрам видеть аутентифицированного пользователя.

3. Регистрация фильтра в SecurityFilterChain

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           JwtAuthFilter jwtAuthFilter) throws Exception {

        http
            // 1️⃣ Делаем приложение безессионным
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

            // 2️⃣ Отключаем стандартный анонимный токен
            .anonymous()
                .disable()
                .and()

            // 3️⃣ Добавляем ваш фильтр *перед* анонимным фильтром
            .addFilterBefore(jwtAuthFilter, AnonymousAuthenticationFilter.class)

            // 4️⃣ Определяем правила авторизации
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/public/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

Почему это работает

  1. Безессионность – никакой HTTP‑сессии не создаётся, поэтому контекст не перезаписывается сессией.
  2. Анонимный токен отключён – убирает fallback, который иначе бы вставил анонимный токен.
  3. Порядок фильтровaddFilterBefore(jwtAuthFilter, AnonymousAuthenticationFilter.class) гарантирует, что JWT будет обработан до анонимного фильтра, так что контекст остаётся аутентифицированным.
  4. АвторизацияanyRequest().authenticated() заставляет каждый эндпоинт (кроме явно разрешённых) требовать валидный токен.

4. Частые ошибки и быстрые проверки

Симптом Вероятная причина Как исправить
401 и лог показывает AnonymousAuthenticationFilter: Set SecurityContextHolder to anonymous Фильтр запускается после анонимного фильтра или вообще не запускается. Проверьте строку addFilterBefore и убедитесь, что путь фильтра совпадает с запросом.
Нет логов из вашего фильтра Фильтр не зарегистрирован или путь не совпадает. Добавьте log.debug("JwtAuthFilter invoked") в начале doFilterInternal() и посмотрите консоль.
SecurityContext заполнен, но всё равно 401 Отсутствуют роли или неверный ant‑matcher. Убедитесь, что UserDetails.getAuthorities() содержит нужные роли для authorizeHttpRequests.
401 на /login или других публичных эндпоинтах Ваш фильтр обрабатывает все URL, включая публичные. Добавьте защиту в фильтр: `if (request.getRequestURI().startsWith(“/public”)

5. Финальный чек‑лист

  1. Фильтр выполняется – лог внутри doFilterInternal().
  2. Токен валидируетсяjwtService.isValid(token) возвращает true.
  3. Объект AuthenticationUsernamePasswordAuthenticationToken создан с ненулевым principal и authorities.
  4. Контекст установленSecurityContextHolder.getContext().setAuthentication(...) до chain.doFilter(...).
  5. Порядок – фильтр добавлен до AnonymousAuthenticationFilter (или до UsernamePasswordAuthenticationFilter, если предпочитаете).
  6. Анонимный токен отключёнhttp.anonymous().disable() или оставьте по умолчанию, но убедитесь, что фильтр запускается первым.
  7. БезессионностьsessionCreationPolicy(SessionCreationPolicy.STATELESS).

Если все пункты выполнены, Spring Security увидит запрос как аутентифицированный, и 401 больше не будет. Токен будет проверен, пользователь загружен, и запрос дойдет до контроллера с корректно заполненным SecurityContext.

Авторы
Проверено модерацией
Модерация