Как исправить 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 всё ещё пустой или содержит анонимный токен. Это происходит, если ваш собственный фильтр:
- Запускается слишком поздно – после AnonymousAuthenticationFilter.
- Не аутентифицирует действительно – просто логирует или ставит контекст, который потом перезаписывается.
- Не зарегистрирован для нужного паттерна URL – запрос просто обходится мимо него.
Ниже – полностью минимальная конфигурация, которая показывает правильный порядок, как строить Authentication и как отключить анонимный fallback.
1. Где должен находиться фильтр в цепочке
| Фильтр | Позиция | Зачем это важно |
|---|---|---|
SecurityContextPersistenceFilter |
1 | Загружает/создаёт SecurityContext. |
Ваш JwtAuthFilter |
2 | Должен работать перед AnonymousAuthenticationFilter. |
AnonymousAuthenticationFilter |
3 | Вставляет анонимный токен, если контекст всё ещё пустой. |
UsernamePasswordAuthenticationFilter |
4 | Обрабатывает форму логина (не используется в JWT). |
ExceptionTranslationFilter |
5 | Вызывает entry point, когда контекст остаётся неаутентифицированным. |
FilterSecurityInterceptor |
6 | Авторизует запрос. |
Если пользовательский фильтр размещён после AnonymousAuthenticationFilter, анонимный токен перекрывает всё, что вы там установили, и запрос всегда будет выглядеть неаутентифицированным.
2. Пример пользовательского фильтра
@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
@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();
}
}
Почему это работает
- Безессионность – никакой HTTP‑сессии не создаётся, поэтому контекст не перезаписывается сессией.
- Анонимный токен отключён – убирает fallback, который иначе бы вставил анонимный токен.
- Порядок фильтров –
addFilterBefore(jwtAuthFilter, AnonymousAuthenticationFilter.class)гарантирует, что JWT будет обработан до анонимного фильтра, так что контекст остаётся аутентифицированным. - Авторизация –
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. Финальный чек‑лист
- Фильтр выполняется – лог внутри
doFilterInternal(). - Токен валидируется –
jwtService.isValid(token)возвращаетtrue. - Объект
Authentication–UsernamePasswordAuthenticationTokenсоздан с ненулевым principal и authorities. - Контекст установлен –
SecurityContextHolder.getContext().setAuthentication(...)доchain.doFilter(...). - Порядок – фильтр добавлен до
AnonymousAuthenticationFilter(или доUsernamePasswordAuthenticationFilter, если предпочитаете). - Анонимный токен отключён –
http.anonymous().disable()или оставьте по умолчанию, но убедитесь, что фильтр запускается первым. - Безессионность –
sessionCreationPolicy(SessionCreationPolicy.STATELESS).
Если все пункты выполнены, Spring Security увидит запрос как аутентифицированный, и 401 больше не будет. Токен будет проверен, пользователь загружен, и запрос дойдет до контроллера с корректно заполненным SecurityContext.