Короткий ответ, если некогда читать
Cookie-сессии — если у вас один-два сервера, серверный рендеринг, админ-панель или личный кабинет с небольшой аудиторией. Просто, безопасно, минимум кода.
JWT — если микросервисная архитектура, мобильные клиенты, межсервисная аутентификация или выдача токенов третьим сторонам. Но будьте готовы решать проблему отзыва токенов.
Всё остальное — нюансы. О них дальше.
Как работают сессии в Spring Security
Вы, вероятно, пишете http.formLogin() и не задумываетесь, что происходит внутри. А происходит следующее:
- Пользователь отправляет логин/пароль.
- Spring Security проверяет credentials через
AuthenticationManager. - Если всё ок — создаёт
SecurityContextс объектомAuthenticationи сохраняет его вHttpSession. - Сессия привязывается к cookie
JSESSIONID(по умолчанию — HttpOnly, Secure). - На каждый следующий запрос браузер автоматически отправляет cookie. Spring достаёт
SecurityContextиз сессии.
Вот конфигурация Spring Security 6 на Kotlin, которая делает именно это:
@Configuration
@EnableWebSecurity
class SessionSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/api/public/**", permitAll)
authorize("/api/admin/**", hasRole("ADMIN"))
authorize(anyRequest, authenticated)
}
formLogin {
loginProcessingUrl = "/api/auth/login"
successHandler = AuthenticationSuccessHandler { _, response, _ ->
response.status = 200
response.writer.write("""{"status":"ok"}""")
}
failureHandler = AuthenticationFailureHandler { _, response, _ ->
response.status = 401
response.writer.write("""{"error":"bad credentials"}""")
}
}
logout {
logoutUrl = "/api/auth/logout"
deleteCookies("JSESSIONID")
}
csrf {
csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse()
}
sessionManagement {
sessionFixation { newSession() }
maximumSessions = 1
}
}
return http.build()
}
}
Три строчки, на которые мало кто обращает внимание:
sessionFixation { newSession() }— защита от session fixation атаки. При логине старая сессия уничтожается, создаётся новая. Без этого атакующий может подкинуть жертве свойJSESSIONID.maximumSessions = 1— один пользователь = одна активная сессия. Второй вход либо убьёт первую сессию, либо будет отклонён (зависит отmaxSessionsPreventsLogin).- CSRF — обязателен для cookie-сессий. Если у вас SPA + cookie-авторизация без CSRF-токена — это дыра.
Где хранятся сессии? По умолчанию — в памяти JVM. Для продакшена с несколькими инстансами это не работает: каждый инстанс видит только свои сессии. Решение — Spring Session + Redis:
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) // 30 мин
class RedisSessionConfig {
@Bean
fun redisConnectionFactory(): LettuceConnectionFactory {
return LettuceConnectionFactory("redis-host", 6379)
}
}
Теперь сессии лежат в Redis, и любой инстанс приложения может их прочитать. Для личных кабинетов с 2-3 инстансами этого достаточно.
Как работает JWT в Spring Security
JWT (JSON Web Token) — это подписанный JSON-объект, который содержит информацию о пользователе (claims). Сервер выдаёт его при логине, клиент хранит его сам (localStorage, cookie, память приложения) и отправляет в заголовке Authorization: Bearer <token>.
Ключевое отличие: сервер не хранит состояние сессии. Он проверяет подпись токена и читает claims. Если подпись валидна — пользователь аутентифицирован.
Минимальная реализация на Spring Security 6 + Kotlin:
Генерация токена
@Service
class JwtService(
@Value("\${jwt.secret}") private val secret: String,
@Value("\${jwt.expiration-ms}") private val expirationMs: Long
) {
private val key: SecretKey = Keys.hmacShaKeyFor(secret.toByteArray())
fun generateToken(user: UserDetails): String {
val now = Instant.now()
return Jwts.builder()
.subject(user.username)
.claim("roles", user.authorities.map { it.authority })
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusMillis(expirationMs)))
.signWith(key)
.compact()
}
fun extractUsername(token: String): String =
parseToken(token).subject
fun isTokenValid(token: String): Boolean =
try { parseToken(token); true }
catch (e: JwtException) { false }
private fun parseToken(token: String): Claims =
Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).payload
}
Фильтр аутентификации
@Component
class JwtAuthenticationFilter(
private val jwtService: JwtService,
private val userDetailsService: UserDetailsService
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
val token = authHeader.substringAfter("Bearer ")
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response)
return
}
val username = jwtService.extractUsername(token)
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails = userDetailsService.loadUserByUsername(username)
val authToken = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.authorities
)
SecurityContextHolder.getContext().authentication = authToken
}
filterChain.doFilter(request, response)
}
}
Security config для JWT
@Configuration
@EnableWebSecurity
class JwtSecurityConfig(
private val jwtAuthFilter: JwtAuthenticationFilter
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/api/auth/**", permitAll)
authorize(anyRequest, authenticated)
}
csrf { disable() } // JWT не использует cookies — CSRF не нужен
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthFilter)
}
return http.build()
}
}
Обратите внимание на SessionCreationPolicy.STATELESS — Spring не будет создавать HttpSession. И csrf { disable() } — потому что JWT передаётся в заголовке, а не в cookie, CSRF тут не применим.
Сравнение по шести критериям
Большинство статей дают вам таблицу «JWT vs Session» и на этом всё. Проблема в том, что эти таблицы обычно повторяют маркетинговые обещания JWT, а не реальное поведение в продакшене.
| Критерий | Cookie-сессия | JWT |
|---|---|---|
| Масштабируемость | Нужен shared store (Redis). 3-5 реплик — не проблема. 50+ — нагрузка на Redis. | Нет серверного хранилища. Но refresh-токены и blacklist всё равно требуют Redis. |
| Безопасность | HttpOnly, Secure, SameSite — браузер защищает cookie. XSS не может прочитать токен. | Если хранить в localStorage — XSS крадёт токен. В HttpOnly cookie — теряется «stateless». |
| Производительность | Один запрос к Redis (~1 ms) на каждый HTTP-запрос. | Без blacklist — 0 сетевых вызовов, только CPU на проверку подписи (~0.1 ms). С blacklist — тот же Redis. |
| Отзыв (revocation) | session.invalidate() — мгновенно. | Невозможен без серверного состояния. Нужен blacklist или короткий TTL + refresh. |
| Мобильные клиенты | Cookies неудобны в нативных приложениях. Нужно возиться с CookieManager. | Authorization: Bearer — стандартный подход для iOS/Android. |
| Сложность реализации | 10 строк конфига. Spring делает всё сам. | JWT filter + token service + refresh endpoint + blacklist. 200-300 строк. |
Вывод из таблицы: JWT не бесплатен. Каждое его преимущество идёт с оговорками. И самая большая оговорка — это revocation.
Миф «JWT — stateless». Почему вам всё равно нужен Redis
Это главное заблуждение, которое я встречаю на проектах. Звучит оно так: «JWT не требует серверного состояния, поэтому масштабируется бесконечно.»
В теории — да. На практике — вам нужно:
-
Отзывать токены при смене пароля. Пользователь сменил пароль — старые токены должны перестать работать. JWT не позволяет это сделать без дополнительного механизма.
-
Отзывать токены при logout. Пользователь нажал «Выйти» — но его JWT живёт ещё 2 часа. На одном проекте, который мы унаследовали, токены не имели expiration вообще. Вечные токены. Бывший сотрудник компании мог зайти в систему через полгода после увольнения.
-
Блокировать пользователей. Администратор заблокировал аккаунт — но у пользователя есть валидный JWT. Без blacklist он продолжает работать до истечения TTL.
-
Refresh-токены. Чтобы access token был коротким (5-15 минут), вам нужен refresh token — а его надо хранить. В БД или в Redis. Это уже серверное состояние.
Вот как выглядит blacklist на Redis:
@Service
class TokenBlacklistService(
private val redisTemplate: StringRedisTemplate
) {
fun blacklist(token: String, expiresIn: Duration) {
redisTemplate.opsForValue().set(
"blacklist:$token", "revoked", expiresIn
)
}
fun isBlacklisted(token: String): Boolean =
redisTemplate.hasKey("blacklist:$token")
}
И фильтр JWT теперь должен проверять blacklist на каждый запрос:
// внутри JwtAuthenticationFilter.doFilterInternal()
if (blacklistService.isBlacklisted(token)) {
response.status = 401
return
}
Поздравляю — вы только что вернули серверное состояние. Теперь у вас JWT + Redis, вместо просто Redis. Сложнее, а результат тот же.
Когда «stateless» JWT действительно работает? Когда вам не нужен revocation — например, межсервисный вызов внутри Kubernetes, где токен живёт 30 секунд и выдаётся service mesh.
Когда JWT действительно оправдан
JWT — правильный выбор в трёх сценариях:
1. Микросервисная архитектура
У вас 10+ сервисов за API Gateway. Gateway проверяет JWT один раз, каждый downstream-сервис читает claims из токена без обращения к центральному auth-сервису. Это снижает нагрузку на auth и убирает single point of failure.
Для Java-бэкендов с десятками микросервисов это стандартный паттерн. Auth-сервис выдаёт JWT, остальные сервисы только проверяют подпись.
2. Мобильные приложения
Нативные iOS/Android-приложения работают с Authorization: Bearer естественнее, чем с cookies. Нет проблем с SameSite, Path, Domain. Токен лежит в Keychain/KeyStore, отправляется в заголовке.
3. Федеративная аутентификация и OAuth 2.0
Если вы выдаёте токены для сторонних клиентов — JWT стандарт де-факто. OpenID Connect построен на JWT. Keycloak, Auth0, Cognito — все выдают JWT.
Для SaaS-платформ с multi-tenant архитектурой JWT удобен: в claims кладётся tenant_id, и каждый сервис знает, от чьего имени пришёл запрос.
Когда сессии — правильный выбор
Серверный рендеринг
Если у вас Spring MVC + Thymeleaf или любой SSR-фреймворк — сессии нативны. Никакого JavaScript для отправки токена, никакого localStorage. Браузер сам отправляет cookie.
Админ-панели и внутренние системы
Десять пользователей, один сервер. Зачем JWT? Зачем вам token service, refresh endpoint, blacklist? http.formLogin() и готово. Отзыв — session.invalidate(). Сложность — минимальная.
Личные кабинеты и порталы
Для клиентских порталов с сотнями-тысячами пользователей cookie-сессии + Spring Session + Redis — золотой стандарт. Мгновенный logout, защита от XSS «из коробки» (HttpOnly), понятная модель безопасности.
SPA с одним бэкендом
Если ваш React/Vue фронтенд общается только с одним Spring Boot API — cookie-сессия с SameSite=Lax проще и безопаснее, чем JWT в localStorage. Фронтенд вообще не знает о токенах — fetch с credentials: 'include' и всё.
Гибридный подход: сессии для веба + JWT для API
На практике часто нужны оба. Веб-версия — SPA с cookie-сессией. Мобильное приложение — JWT. Внешние интеграции — API-ключи. Вот конфигурация Spring Security 6, которая разделяет потоки:
@Configuration
@EnableWebSecurity
class HybridSecurityConfig(
private val jwtAuthFilter: JwtAuthenticationFilter
) {
@Bean
@Order(1)
fun apiSecurityChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/api/v2/**")
authorizeHttpRequests {
authorize("/api/v2/auth/**", permitAll)
authorize(anyRequest, authenticated)
}
csrf { disable() }
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthFilter)
}
return http.build()
}
@Bean
@Order(2)
fun webSecurityChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/login", permitAll)
authorize("/assets/**", permitAll)
authorize(anyRequest, authenticated)
}
formLogin {
loginPage = "/login"
}
csrf {
csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse()
}
sessionManagement {
maximumSessions = 1
}
}
return http.build()
}
}
@Order(1) для API-цепочки — Spring Security проверяет цепочки по порядку. Запросы на /api/v2/** обрабатываются JWT-фильтром, всё остальное — через cookie-сессию.
Мы применяли этот подход в финтех-проекте: веб-интерфейс оператора — сессия + CSRF, мобильное приложение клиента — JWT, POS-терминалы — mTLS + API key.
Типичные ошибки безопасности
Вот что мы находим на аудитах. И в JWT-проектах, и в session-based.
Ошибки с JWT
1. Хранение в localStorage.
// ТАК ДЕЛАТЬ НЕЛЬЗЯ
localStorage.setItem('token', response.data.token)
Любой XSS-скрипт на вашей странице выполнит localStorage.getItem('token') и отправит его на свой сервер. Вы скажете «у нас нет XSS». Ваша зависимость от npm может иметь. Одно уязвимое место в node_modules — и все токены утекли.
Альтернатива: HttpOnly cookie. Но тогда вы теряете «stateless» и добавляете CSRF. Или Authorization header + in-memory storage (при перезагрузке страницы — re-login).
2. Отсутствие expiration.
// Плохо — токен живёт вечно
Jwts.builder()
.subject(user.username)
.signWith(key)
.compact() // нет .expiration()
Access token без exp — это ключ от квартиры, который никогда не истекает. Утёк один раз — доступ навсегда.
3. Алгоритм none.
Старые версии библиотек (до jjwt 0.12) позволяли обойти подпись, поменяв алгоритм на none. Убедитесь, что ваша библиотека требует конкретный алгоритм при парсинге:
// Правильно — явно указываем ключ
Jwts.parser().verifyWith(key).build()
// Опасно — старый API без привязки к алгоритму
Jwts.parser().setSigningKey(secret).build() // deprecated
4. Секрет в коде.
# application.yml — НЕТ
jwt:
secret: "my-super-secret-key-12345"
Секрет в Git-репозитории = компрометация всех токенов. Используйте переменные окружения, Vault, Kubernetes Secrets.
Ошибки с сессиями
1. Отсутствие CSRF-защиты.
Если у вас cookie-авторизация без CSRF-токена — злоумышленник может выполнить действие от имени пользователя. Пользователь заходит на вредоносную страницу, та отправляет POST на ваш API — cookie улетит автоматически.
2. Session fixation.
// Плохо — сессия не пересоздаётся при логине
sessionManagement {
sessionFixation { none() } // УЯЗВИМО
}
По умолчанию Spring Security пересоздаёт сессию. Но я видел проекты, где none() ставился «чтобы не терять корзину при логине». Результат — session fixation attack.
3. Чрезмерный TTL сессии.
server:
servlet:
session:
timeout: 24h # не делайте так
24 часа — это слишком. Для финансовых приложений — 15-30 минут. Для обычных — 1-2 часа. Для админок — 30 минут с sliding expiration.
Миграция: с сессий на JWT (Spring Security 6)
Допустим, у вас монолит на сессиях, и вы разбиваете его на микросервисы. Или добавляете мобильное приложение. Нужен JWT — но ломать работающий веб нельзя.
План миграции в четыре шага:
Шаг 1. Добавить JWT-инфраструктуру рядом с существующей
Создайте JwtService, JwtAuthenticationFilter, endpoint /api/auth/token. Не трогайте существующий session-based конфиг.
Шаг 2. Разделить security chains
@Bean
@Order(1) // JWT-цепочка — для /api/v2/**
fun jwtChain(http: HttpSecurity): SecurityFilterChain { /* ... */ }
@Bean
@Order(2) // Session-цепочка — для остального
fun sessionChain(http: HttpSecurity): SecurityFilterChain { /* ... */ }
Старые клиенты продолжают работать через сессии. Новые (мобилки, микросервисы) используют JWT через /api/v2/.
Шаг 3. Вынести пользователей в общий UserDetailsService
Обе цепочки должны использовать один и тот же UserDetailsService. Один источник правды — одна БД пользователей, одна логика ролей.
@Service
class UnifiedUserDetailsService(
private val userRepo: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepo.findByEmail(username)
?: throw UsernameNotFoundException("User not found: $username")
return User.builder()
.username(user.email)
.password(user.passwordHash)
.roles(*user.roles.toTypedArray())
.build()
}
}
Шаг 4. Реализовать refresh + blacklist
Без этого JWT нежизнеспособен в продакшене. Минимум:
- Access token: 15 минут.
- Refresh token: 7 дней, хранится в БД (можно Redis) с привязкой к
user_idиdevice_id. - Endpoint
/api/auth/refresh— принимает refresh, выдаёт новый access. - При logout/смене пароля — все refresh-токены пользователя удаляются из БД.
@Entity
@Table(name = "refresh_tokens")
data class RefreshToken(
@Id val id: UUID = UUID.randomUUID(),
val userId: Long,
val token: String = UUID.randomUUID().toString(),
val deviceId: String,
val expiresAt: Instant = Instant.now().plus(7, ChronoUnit.DAYS),
val createdAt: Instant = Instant.now()
)
@PostMapping("/api/auth/refresh")
fun refresh(@RequestBody request: RefreshRequest): ResponseEntity<TokenResponse> {
val stored = refreshTokenRepo.findByToken(request.refreshToken)
?: return ResponseEntity.status(401).build()
if (stored.expiresAt.isBefore(Instant.now())) {
refreshTokenRepo.delete(stored)
return ResponseEntity.status(401).build()
}
val user = userDetailsService.loadUserByUsername(
userRepo.findById(stored.userId)!!.email
)
val newAccess = jwtService.generateToken(user)
return ResponseEntity.ok(TokenResponse(accessToken = newAccess))
}
Чек-лист: какой подход выбрать
Перед тем как писать код — ответьте на пять вопросов:
-
Сколько у вас бэкенд-сервисов? Один монолит → сессии. Микросервисы → JWT (или OAuth 2.0 с Keycloak).
-
Есть ли мобильные клиенты? Да → JWT для мобилки, сессии для веба (гибрид).
-
Нужен ли мгновенный logout? Если критично (финтех, медицина) → сессии или JWT + blacklist.
-
Какая нагрузка? До 10K RPS — разницы нет. Свыше — JWT без blacklist экономит ~1 ms на запрос.
-
Внешние клиенты? Если отдаёте API третьим сторонам → JWT (OAuth 2.0).
Для типичного Java-проекта — монолита с SPA-фронтендом и 1-3 инстансами — сессии проще и безопаснее. JWT добавляет сложность, которая окупается только в специфичных сценариях.
Итого
JWT — не серебряная пуля. Сессии — не устаревший подход. Большинство туториалов ставят JWT по умолчанию, потому что «модно» и «stateless». В реальности вы получаете 300 строк инфраструктурного кода, обязательный blacklist, и тот же Redis, что и для сессий.
Если вы строите личный кабинет или внутреннюю систему — начните с сессий. Если вам потом понадобится JWT для мобилки — добавите вторую security chain за полдня. Гибридный подход работает.
Если строите распределённую систему на Java с десятком сервисов и мобильным приложением — JWT оправдан, но только с refresh-токенами, blacklist и продуманной стратегией отзыва.
Выбирайте инструмент под задачу. Не под хайп.