Учебный пример состоит из трёх строк: пользователь в памяти, один permitAll и запрос к защищённой странице. Всё работает, страница отдаёт 200, новичок доволен. Продакшен начинается, когда к этим строкам добавляются токены, унаследованные сессии, собственный фильтр и три набора правил для разных частей API. Фреймворк честно всё это умеет, но его модель из фильтров и провайдеров устроена иначе, чем кажется по примерам, и любое расхождение проявляется не текстом ошибки, а кодом 403 без объяснений. Ниже разбор архитектуры, типовых разрывов между туториалом и боевой конфигурацией и инструментов, которые заменяют угадывание диагностикой.
Spring Security вырос из проекта Acegi и сменил имя в 2008 году со второй версией. Ветка 6.0 в ноябре 2022 года потребовала Java 17 и убрала класс WebSecurityConfigurerAdapter, на котором держались тысячи статей и ответов на форумах: конфигурация переехала на бины SecurityFilterChain. Дальше релизы шли дважды в год, от 6.2 в ноябре 2023 года до 6.5 в мае 2025 года. Седьмая версия вышла в ноябре 2025 года вместе со стеком Spring Framework 7, летом 2026-го обновилась до 7.1, и к осени 2026 года актуальны выпуски 7.1.1 и 7.0.7.
Фильтры как архитектура безопасности и их настоящий порядок
Входная точка всего это специальный фильтр-мост между контейнером сервлетов и контекстом Spring. Он передаёт запрос механизму цепочек, тот перебирает зарегистрированные цепочки и выбирает первую, чьё правило сопоставления совпало с путём. Дальше запрос проходит все фильтры выбранной цепочки по порядку, и этот порядок решает половину вопросов эксплуатации.
Цепочка по умолчанию в приложении на Spring Boot насчитывает около пятнадцати фильтров. Ключевые идут в строгой последовательности: сначала DisableEncodeUrlFilter, затем SecurityContextHolderFilter, восстанавливающий контекст аутентификации, HeaderWriterFilter с защитными заголовками, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, генератор стандартной страницы входа, BasicAuthenticationFilter, фильтр запомненного запроса, AnonymousAuthenticationFilter, и только в конце пара из ExceptionTranslationFilter и AuthorizationFilter, где первый переводит отказы доступа в коды ответов, а второй принимает само решение.
Порядок объясняет типовую загадку новичков. Фильтр перевода исключений стоит перед фильтром авторизации, поэтому неаутентифицированный вызов закрытого ресурса получает не трассировку стека, а аккуратный 401. Фильтр авторизации последний, и всё, что вставлено после него, уже не влияет на решение о доступе.
Провайдеры аутентификации и кодировщики паролей за фасадом учебника
За интерфейсом AuthenticationManager прячется цепочка провайдеров. На практике один ProviderManager делегирует DaoAuthenticationProvider, тот берёт сведения пользователя из UserDetailsService и сверяет пароль через PasswordEncoder. Классика первого продакшена:
java.lang.IllegalArgumentException: There is no PasswordEncoder
mapped for the id null
Причина проста: пароли в учебниках хранятся открытым текстом, а DelegatingPasswordEncoder требует префикс схемы перед хешем, вида {bcrypt}$2a$10$. Строка без префикса не распознаётся как известная схема. Лечение: хранить хеши с префиксом, а при входе владельцев старых записей тихо пересчитывать пароль до актуальной схемы. Побочный смысл силы хеширования: BCrypt с силой десять считается десятки-сотню миллисекунд, и одна эта настройка сжимает скорость стороннего перебора паролей на порядки.
Учебный InMemoryUserDetailsManager с двумя пользователями в бою заменяется загрузкой из базы: собственный UserDetailsService поверх JPA, готовый JdbcUserDetailsManager или обращение к корпоративному каталогу. К этой же обвязке добавляются блокировка учётной записи после серии неудачных входов, отметка времени последнего визита и требование сменить устаревший пароль. Ни одна из этих деталей не показана в стартовом примере.
Несколько цепочек фильтров и порядок их сопоставления с путями
Реальное приложение редко обходится одной цепочкой. Типовая пара: открытый API на токенах и веб-интерфейс на сессиях. Каждая цепочка объявляется бином с приоритетом:
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth ->
oauth.jwt(Customizer.withDefaults()));
return http.build();
}
Приоритет решает: цепочка с securityMatcher("/api/**") обязана стоять выше общей, иначе запросы к API попадут в форму входа и вернут HTML вместо JSON. Внутри authorizeHttpRequests правила просматриваются сверху вниз до первого совпадения, поэтому anyRequest().authenticated() в начале списка обесценивает всё написанное ниже. Отдельная ловушка с защитой от подделки межсайтовых запросов: отключать её целиком ради stateless API не обязательно, а вот выключить для браузерного интерфейса без замены на что-то осмысленное означает оставить форму входа беззащитной перед чужой страницей.
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://auth.example.com/.well-known/jwks.json
Свойство ресурсного сервера передаёт адрес набора ключей, после чего NimbusJwtDecoder проверяет подпись и время жизни токена, а расхождение часов серверов учтено допуском по умолчанию.
Соседняя ловушка связана с межсайтовыми запросами. Конфигурация CORS в Spring MVC не распространяется на цепочки фильтров, и Spring Security перехватывает предварительный OPTIONS-запрос, отдавая 403 ещё до того, как до него доберётся контроллер. Лечение двустороннее: бин CorsConfigurationSource и явная передача его в настройку:
http.cors(cors -> cors.configurationSource(corsSource()));
Предварительные запросы после этого проходят, а правила допустимых источников живут в одном месте.
Сессии куки и remember me в роли забытых настроек
Веб-интерфейс на сессиях приносит собственный набор правил, о которых стартовый пример молчит. Защита от фиксации сессии по умолчанию меняет идентификатор при входе, со времён Servlet 3.1 это метод changeSessionId. Сама кука JSESSIONID без явной настройки не получает ни флага SameSite, ни флага Secure: контейнер ставит их только по указанию, а уходит кука в браузер с каждым ответом. CookieSerializer из Spring Session или настройки контейнера закрывают вопрос несколькими строками.
Политика создания сессий выбирается из четырёх состояний: ALWAYS заводит сессию каждому, IF_REQUIRED только по необходимости, NEVER не создаёт, но использует готовую, STATELESS не создаёт и не читает вовсе. Для API на токенах правильный ответ STATELESS, случайная щедрость политики означает напрасную нагрузку на хранилище сессий.
Функция remember me выглядит подарком: один флаг в настройке, и пользователь живёт в системе после перезапуска браузера. Цена известна: кука с долгим сроком жизни, чей угон открывает учётную запись надолго. Разумная конфигурация ограничивает срок, проверяет совпадение пары из серии и токена и разрешает длительный вход только узкому кругу действий.
Свой фильтр в цепочке и потерянный между запросами контекст
Проверка токена на каждом вызове оформляется фильтром:
public class JwtTokenFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain) throws IOException {
String token = extractToken(req);
if (token != null) {
Authentication auth = jwtDecoder.decode(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(req, res);
}
}
Вставка в цепочку выполняется с привязкой к известному месту:
http.addFilterBefore(new JwtTokenFilter(decoder),
UsernamePasswordAuthenticationFilter.class);
Главная ловушка прячется не в самом фильтре. Контекст, записанный через SecurityContextHolder, живёт в пределах одного запроса, и для сохранения между запросами его сберегает SecurityContextRepository: сессия или атрибут запроса, сводит выбор DelegatingSecurityContextRepository. Фильтр, который вызвал setAuthentication и не позаботился о сохранении, даёт классическую картину: первый запрос прошёл, а второй снова анонимный. В шестой версии ветка SecurityContextPersistenceFilter уступила место SecurityContextHolderFilter, и чтение сохранённого контекста стало явной частью конвейера.
Методная безопасность с обёртками и её типовые ловушки
Аннотации переносят проверку с адреса на метод сервиса:
@PreAuthorize("hasRole('ADMIN') or #order.owner == principal.username")
public void approve(Order order) {
orderRepository.save(order);
}
С шестой версии включение выполняется аннотацией @EnableMethodSecurity, прежняя @EnableGlobalMethodSecurity отправлена на покой вместе с обязательными префиксами атрибутов. Ловушки те же, что у любой АОП-обёртки: внутренний вызов одного метода другим в том же объекте идёт мимо обёртки, и проверка молча не срабатывает; финальные методы не переопределяются и остаются незащищёнными; тест, вызывающий метод напрямую, вообще не встречает обёртки. Безопасность на методах требует отдельного набора тестов через защищённый вызов, иначе она существует только на бумаге.
Длинные выражения в SpEL быстро превращаются в нечитаемую конструкцию, которую страшно менять. Практика выравнивания выносит правило в бин и вызывает его из аннотации:
@PreAuthorize("@orderRules.canApprove(#order, principal)")
public void approve(Order order) {
Логика доступа переезжает в обычный класс с тестами, а аннотация остаётся короткой ссылкой.
Разбор неполадок по журналам и тестам вместо слепого поиска
Первый инструмент диагностики стоит одной строки:
logging.level.org.springframework.security=TRACE
После этого журнал показывает каждый шаг: какую цепочку выбрала обёртка, какой фильтр остановил запрос, какое правило авторизации сработало. Частые диагнозы угадываются по коду ответа. 403 на POST почти всегда защита от подделки. 403 на страницу внутри iframe это стандартный заголовок X-Frame-Options со значением DENY из настроек по умолчанию. 401 вместо переадресации на вход означает, что запрос попал в stateless цепочку, где анонимность отдаётся отказом.
Интеграционные тесты пишутся через утилиты для MockMvc:
mockMvc.perform(post("/orders")
.with(csrf())
.with(user("admin").roles("ADMIN")))
.andExpect(status().isOk());
Секреты не хранят в файлах конфигурации в открытом виде: адреса и ключи приходят переменными окружения или из хранилища секретов, доступ к которому размечен правами. Стандартные заголовки ответов, HSTS для HTTPS, запрет изменения типов содержимого, ограничение встраивания в рамки, работают из коробки, и их отключение должно быть осознанным актом, а не случайным остатком отладки.
Разрыв между учебным примером и боевым конфигом обычно собирается из таких мест:
- пользователи из памяти учебника заменяются загрузкой из базы или каталога, и появляется блокировка после серии неудачных входов;
- permitAll, поставленный на время отладки, переезжает в конфигурацию и добирается до релиза незамеченным;
- защиту от подделки запросов отключают целиком вместо точечной настройки для stateless API и ловят 403 на первом POST-вызове;
- пользовательский фильтр записывает контекст аутентификации без сохранения в репозиторий, и между запросами состояние исчезает;
- правило anyRequest().authenticated() пишется раньше специфичных правил для путей, и вся задуманная логика сводится к одной строке;
- методы защищаются аннотациями, но внутренние вызовы объекта идут мимо обёртки, и проверка молча не работает.
Учебные примеры не лгут, они описывают минимальный каркас. Настоящая сложность Spring Security живёт не в аннотациях, а в потоке запроса через цепочки, и её нельзя скопировать вставкой. Команда, которая представляет порядок фильтров, знает, где живёт контекст, и держит правила авторизации под тестами, получает управляемую систему. Команда, которая переносит в проект третий по счёту фрагмент из поисковой выдачи, получает хрупкий набор случайных вставок, где каждая правка это лотерея.