DEV Community

Cover image for JWT vs Session Tokens in Spring Boot: A Senior Dev's Decision Guide
Davide Mibelli
Davide Mibelli

Posted on • Originally published at Medium

JWT vs Session Tokens in Spring Boot: A Senior Dev's Decision Guide

Three years ago I gave the same answer every time someone asked me about authentication in Spring Boot: "use JWT, it's stateless, it scales." I was half right and half wrong, and it took inheriting two production codebases — one broken in a very specific way — to understand which half was which.

This is not a tutorial on how to implement either one. It's the decision guide I wish I'd had before I started recommending JWT by default.

What tutorials actually teach you

Most Spring Boot security tutorials walk you through JWT because it makes for a cleaner demo. You add a filter, validate a signature, set the SecurityContext, done. No database calls, no shared state, stateless by construction. It feels architecturally clean.

What they rarely show: what happens when a user changes their password. Or gets their account suspended. Or logs out on one device and expects that to mean something on all devices. With a pure JWT setup and no blocklist, the answer to all three is "nothing happens until the token expires."

Sessions, on the other hand, feel old-fashioned. "That doesn't scale." "You need sticky sessions." Neither of those is true anymore, and I'll show you why.

How sessions actually work in Spring Boot

Spring Session with Redis is three annotations and a dependency:

@EnableRedisHttpSession
@Configuration
public class SessionConfig {
    // Spring Session handles everything else
}
Enter fullscreen mode Exit fullscreen mode
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

The client gets an opaque session ID (typically 32 hex characters) stored in an HttpOnly; Secure cookie. Every request sends the cookie, Spring looks up the session in Redis, deserializes it, and populates the SecurityContext. The session data lives in Redis, not in the token itself.

Revocation is sessionRepository.deleteById(sessionId). Instant, no exceptions.

Horizontal scaling works out of the box — every instance connects to the same Redis. No sticky sessions needed. This is not 2009 anymore.

Side-by-side flow comparison — Session flow: client cookie to server to Redis lookup to SecurityContext; JWT flow: client Bearer token to server to local signature verify to SecurityContext. Annotate the revocation point on sessions (delete from Redis) and the revocation gap on JWT (no early invalidation without blocklist)

JWT: what you actually get

A JWT is a base64-encoded JSON payload (claims) signed with a secret or private key. The server does not store it. Verification happens locally by checking the signature — no database call, no network hop.

This matters in two specific situations:

Microservices that verify tokens independently. If you have five services and each needs to know who the caller is, sessions require every service to call a shared store or a central auth service. JWT lets each service verify the token locally with just the public key or shared secret.

Third-party and mobile clients. Browsers handle cookies automatically. Native apps and third-party API clients do not. JWT in the Authorization header works everywhere without cookie configuration.

Outside these two cases, you are paying the JWT costs without getting the JWT benefits.

The costs are real:

  • No revocation without a blocklist. A 15-minute access token cannot be invalidated early. A stolen token is valid until expiry. If you add a Redis blocklist to check on every request, you have just re-added the database call you were trying to avoid.
  • Token size. A session cookie is 32 bytes. A JWT with a handful of claims is 300–600 bytes in every request header, forever. In high-frequency internal APIs this adds up.
  • Implementation surface. JWT has a long history of security bugs: alg: none attacks, weak HMAC secrets, missing expiry validation, incorrect audience checks. Spring Security handles most of this correctly, but the complexity budget is higher than sessions.

The real decision framework

Stop asking "which is better?" and ask "what do I actually need?"

Use sessions when:

  • You control the frontend — a browser-based app using your own backend
  • You need immediate revocation: logout means logout, password change means all sessions die
  • You are building a monolith or a small service that owns its own auth
  • You are already running Redis for caching or queuing

Use JWT when:

  • Multiple independent services need to verify identity without calling a central store
  • You have non-browser clients — mobile apps, CLI tools, third-party integrations — that cannot easily handle cookies
  • You need federated identity: a token issued by an external IdP (Auth0, Keycloak, Cognito) that your service validates

Do not use JWT because:

  • "It's stateless and scales" — sessions on Redis scale just as well across any number of instances
  • "Everyone uses it" — cargo-culting security decisions is how you end up with 7-day non-revocable tokens in production

The hybrid setup nobody talks about

Most production systems I've seen that do this well use a combination: sessions for the browser frontend, JWT for service-to-service calls and mobile clients.

Spring Security supports this cleanly with multiple SecurityFilterChain beans:

@Bean
@Order(2)
public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/v1/**")
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(AbstractHttpConfigurer::disable)
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

@Bean
@Order(1)
public SecurityFilterChain sessionFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/web/**")
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
        .formLogin(Customizer.withDefaults())
        .logout(logout -> logout.logoutSuccessUrl("/web/login?logout"))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/web/login").permitAll()
            .anyRequest().authenticated());
    return http.build();
}
Enter fullscreen mode Exit fullscreen mode

Two chains, different matchers, different strategies. The browser app gets sessions and full revocation. API and service calls get JWT. Spring applies them in @Order sequence — the first matching chain wins.

What the performance difference actually looks like

Sessions add one Redis round-trip per request — typically 0.5–2ms on a well-configured local Redis, 2–5ms if Redis is in a separate availability zone. For a request that already takes 50–200ms to process, that is noise.

JWT validation is in-memory: parse the base64, verify the HMAC signature, check expiry. Sub-millisecond. If you are building an API that needs to handle thousands of requests per second with microsecond budgets, this difference matters. If you are building a standard web application or a business API, it does not.

The token size difference matters more than you would expect in aggregate. A session cookie is SESSION=<32-hex-chars> — about 50 bytes in the Cookie header. A JWT is Authorization: Bearer <base64> — typically 400–700 bytes in the Authorization header, on every single request. In an application with 10,000 active users making 20 requests per hour, that is roughly 70MB/hour in header overhead alone versus 5MB with sessions. On internal microservice APIs with high call frequency, this adds up to real cost.

Neither of these is a reason to choose one over the other by itself. They are factors to weigh against the architectural fit, not arguments that make the decision for you.

Security mistakes that are easy to make with JWT

Spring Security protects you from many JWT pitfalls if you use it correctly, but I have seen all of these in production codebases:

Symmetric secret too short. HS256 requires at least 256 bits (32 bytes). A short secret is brute-forceable. Generate it with openssl rand -base64 32 and store it in your secrets manager, not in application.yml.

No audience or issuer validation. If you have multiple services accepting the same JWT, a token issued for service A can be replayed against service B unless you validate the aud and iss claims. Spring Security's JwtDecoder supports this with .claimValidator("aud", ...).

Logging the token. Access logs, debug statements, error traces. A JWT is a credential. Treat it like a password in your logging configuration.

Using RS256 in a monolith. RS256 (asymmetric) makes sense when multiple services need to verify tokens issued by a single auth service. In a monolith where only one service issues and verifies tokens, RS256 adds key management complexity with no security benefit. HS256 is the right default.

The mistake I see most often

Teams start with JWT because a tutorial recommended it. Six months later, they need to implement "logout from all devices" or "force re-authentication after a password change." At that point they bolt on a Redis blocklist to invalidate tokens before expiry — which means every JWT validation now hits Redis on every request. They have kept all the JWT complexity and added the session store on top.

I have done this. The resulting code is harder to reason about than either pure sessions or pure JWT would have been.

If you need revocation, use sessions. If you genuinely need stateless cross-service verification, use JWT and design around the revocation limitation deliberately — 15-minute access tokens, refresh token rotation, a clear session invalidation policy documented before you ship — not as an afterthought six months later.

application.yml showing spring.session.store-type=redis, spring.session.timeout=30m, server.servlet.session.cookie.http-only=true, server.servlet.session.cookie.secure=true — the minimal Spring Session Redis config needed to replace an overly complex JWT setup

The boring answer is often the right one. Spring Session with Redis has been solving this problem correctly since 2015. JWT solves a specific distributed systems problem. Know which one you actually have before you choose.

What does your current setup use, and was it a deliberate choice or did it come from a tutorial?


Originally published on Medium.

Top comments (0)