Skip to main content
the auth layer

The State You Are Hiding: Sessions, Tokens, and the Revocation Problem

5 min read Chapter 2 of 45

The State You Are Hiding

The Assumption

Most implementations treat token choice as a deployment convenience. JWT tutorials present tokens as a stateless upgrade over sessions, a way to avoid database lookups on every request. The implicit promise: put the user’s identity in a signed token, validate the signature on each request, never touch a database.

This framing omits the revocation problem entirely. It also omits the information leakage problem: a JWT is a base64-encoded JSON object. Anyone with the token can read every claim in it. The token is not encrypted by default. It is signed, which proves integrity, but it does not provide confidentiality.

The decision between sessions and tokens is not a deployment preference. It is a security architecture decision with consequences for revocation latency, information exposure, and operational complexity.

The Attack

Token theft with delayed revocation. An attacker obtains a valid JWT through XSS, a man-in-the-middle position, or a compromised logging system that accidentally recorded Authorization headers. The security team detects the compromise and needs to revoke the token immediately.

With a session-based system: delete the session from Redis. The next request with that session ID fails authentication. Time to revocation: milliseconds.

With a JWT-based system: the token is cryptographically valid. No server-side state exists to invalidate. The token will continue to be accepted by every resource server until its exp claim passes. If the token has a 15-minute lifetime, the attacker has 15 minutes of access after detection. If the token has a 1-hour lifetime (common in tutorials), the attacker has an hour.

The standard mitigations each reintroduce state:

  • A revocation list (checked on every request) reintroduces the database lookup that JWTs were supposed to eliminate.
  • Short token lifetimes (1-5 minutes) require constant refresh token rotation, which introduces race conditions under concurrent requests (covered in Chapter 5).
  • Token introspection (calling the authorization server on every request) makes the JWT functionally equivalent to an opaque token with extra bytes.

The Spec or Mechanism

RFC 6750 defines the Bearer Token Usage specification. Section 5.2 identifies token theft as a threat and recommends TLS and short-lived tokens as mitigations. It does not solve the revocation problem because bearer tokens are, by definition, valid if possessed. The spec acknowledges this limitation explicitly.

Spring Security implements two distinct authentication paths that correspond to the session-token spectrum:

Session-based path: HttpSecurity.formLogin() or HttpSecurity.oauth2Login() creates a SecurityContext stored in the HttpSession. The session ID is sent to the client as a cookie. Spring Session can externalize this to Redis for distributed access.

Token-based path: HttpSecurity.oauth2ResourceServer() validates bearer tokens on each request. With JWT configuration, validation is local (signature check against cached public key). With opaque token configuration, validation requires an introspection call to the authorization server.

These are not interchangeable deployment options. They are fundamentally different security architectures with different properties.

The Implementation

// VULNERABLE: JWT for browser-facing application with no revocation path
@Bean
public SecurityFilterChain browserApiFilterChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/api/**")
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt
                .decoder(jwtDecoder())
            )
        )
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .build();
}
// Problem: compromised token cannot be revoked until exp.
// Problem: token claims visible to any intermediary.
// Problem: logout is a client-side fiction; server has no state to clear.
// HARDENED: Server-side session for browser-facing application
@Bean
public SecurityFilterChain browserSessionFilterChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/app/**")
        .oauth2Login(oauth2 -> oauth2
            .userInfoEndpoint(userInfo -> userInfo
                .oidcUserService(customOidcUserService())
            )
        )
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
            .maximumSessions(3)
            .sessionRegistry(redisSessionRegistry())
        )
        .logout(logout -> logout
            .logoutSuccessHandler(oidcLogoutSuccessHandler())
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID")
        )
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        )
        .build();
}
// Session stored in Redis via Spring Session.
// Revocation: delete session key from Redis → immediate.
// Logout: session invalidated, cookie cleared, OIDC back-channel logout triggered.
// CSRF protection enabled because session cookies are sent automatically.
// HARDENED: JWT for service-to-service communication (correct use case)
@Bean
public SecurityFilterChain serviceApiFilterChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/internal/**")
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt
                .decoder(NimbusJwtDecoder.withJwkSetUri(jwksUri)
                    .jwsAlgorithm(SignatureAlgorithm.RS256)
                    .build())
                .jwtAuthenticationConverter(serviceJwtAuthenticationConverter())
            )
        )
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .csrf(csrf -> csrf.disable()) // No browser, no cookies, no CSRF
        .build();
}
// JWT is correct here: service-to-service, no browser, no revocation need
// within the token's short lifetime (2-5 minutes).
// Audience validation enforced in serviceJwtAuthenticationConverter().

The Verification

@SpringBootTest
@AutoConfigureMockMvc
class SessionRevocationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private SessionRepository<? extends Session> sessionRepository;

    @Test
    void revokedSessionRejectsSubsequentRequests() throws Exception {
        // Authenticate and get session
        MvcResult loginResult = mockMvc.perform(
                formLogin("/login").user("alice").password("password"))
            .andExpect(status().is3xxRedirection())
            .andReturn();

        String sessionId = loginResult.getRequest().getSession().getId();

        // Verify session works
        mockMvc.perform(get("/app/dashboard")
                .cookie(new Cookie("JSESSIONID", sessionId)))
            .andExpect(status().isOk());

        // Revoke: delete session from Redis
        sessionRepository.deleteById(sessionId);

        // Verify revocation is immediate
        mockMvc.perform(get("/app/dashboard")
                .cookie(new Cookie("JSESSIONID", sessionId)))
            .andExpect(status().isUnauthorized());
    }
}

The test proves that session revocation is immediate. No waiting for token expiry. No revocation list propagation delay. Delete the session, and the next request fails. This is the security property that JWT-based browser authentication cannot provide without reintroducing server-side state.