Новаком
JAVA

JWT vs Session: что выбрать для авторизации в Spring Boot — гайд 2026

JWT vs cookie-сессии в Spring Security: когда что использовать, плюсы и минусы, безопасность, производительность. Код на Kotlin, примеры конфигурации, миграция.

Н
Новаком
2026-05-24 · 10 минут чтения

Короткий ответ, если некогда читать

Cookie-сессии — если у вас один-два сервера, серверный рендеринг, админ-панель или личный кабинет с небольшой аудиторией. Просто, безопасно, минимум кода.

JWT — если микросервисная архитектура, мобильные клиенты, межсервисная аутентификация или выдача токенов третьим сторонам. Но будьте готовы решать проблему отзыва токенов.

Всё остальное — нюансы. О них дальше.


Как работают сессии в Spring Security

Вы, вероятно, пишете http.formLogin() и не задумываетесь, что происходит внутри. А происходит следующее:

  1. Пользователь отправляет логин/пароль.
  2. Spring Security проверяет credentials через AuthenticationManager.
  3. Если всё ок — создаёт SecurityContext с объектом Authentication и сохраняет его в HttpSession.
  4. Сессия привязывается к cookie JSESSIONID (по умолчанию — HttpOnly, Secure).
  5. На каждый следующий запрос браузер автоматически отправляет 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 не требует серверного состояния, поэтому масштабируется бесконечно.»

В теории — да. На практике — вам нужно:

  1. Отзывать токены при смене пароля. Пользователь сменил пароль — старые токены должны перестать работать. JWT не позволяет это сделать без дополнительного механизма.

  2. Отзывать токены при logout. Пользователь нажал «Выйти» — но его JWT живёт ещё 2 часа. На одном проекте, который мы унаследовали, токены не имели expiration вообще. Вечные токены. Бывший сотрудник компании мог зайти в систему через полгода после увольнения.

  3. Блокировать пользователей. Администратор заблокировал аккаунт — но у пользователя есть валидный JWT. Без blacklist он продолжает работать до истечения TTL.

  4. 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))
}

Чек-лист: какой подход выбрать

Перед тем как писать код — ответьте на пять вопросов:

  1. Сколько у вас бэкенд-сервисов? Один монолит → сессии. Микросервисы → JWT (или OAuth 2.0 с Keycloak).

  2. Есть ли мобильные клиенты? Да → JWT для мобилки, сессии для веба (гибрид).

  3. Нужен ли мгновенный logout? Если критично (финтех, медицина) → сессии или JWT + blacklist.

  4. Какая нагрузка? До 10K RPS — разницы нет. Свыше — JWT без blacklist экономит ~1 ms на запрос.

  5. Внешние клиенты? Если отдаёте API третьим сторонам → JWT (OAuth 2.0).

Для типичного Java-проекта — монолита с SPA-фронтендом и 1-3 инстансами — сессии проще и безопаснее. JWT добавляет сложность, которая окупается только в специфичных сценариях.


Итого

JWT — не серебряная пуля. Сессии — не устаревший подход. Большинство туториалов ставят JWT по умолчанию, потому что «модно» и «stateless». В реальности вы получаете 300 строк инфраструктурного кода, обязательный blacklist, и тот же Redis, что и для сессий.

Если вы строите личный кабинет или внутреннюю систему — начните с сессий. Если вам потом понадобится JWT для мобилки — добавите вторую security chain за полдня. Гибридный подход работает.

Если строите распределённую систему на Java с десятком сервисов и мобильным приложением — JWT оправдан, но только с refresh-токенами, blacklist и продуманной стратегией отзыва.

Выбирайте инструмент под задачу. Не под хайп.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект