skip to content

Why is server-side logout largely ineffective for stateless JWT authentication, and how do you design real logout for it?

level: principalimportance: should knowfreq 22%

answer

  1. JWT validated by signature+exp, not server state
  2. no session -> logout is a no-op
  3. short access token + revocable refresh token
  4. denylist jti in Redis with TTL = remaining life
  5. custom LogoutHandler does the revocation

basics

~20 s

A JWT is self-contained and validated by signature, not by server state, so invalidating a session does nothing — the token stays valid until it expires. Real logout needs short-lived tokens plus a server-side denylist or a rotated refresh token.

solid answer

~40 s

Spring's default logout tears down session state: SecurityContextLogoutHandler clears the context and invalidates the HttpSession. But a stateless JWT app has no session — each request is authenticated purely by verifying the token's signature and expiry. So 'logging out' on the server changes nothing; a copy of the token keeps working until exp. To make logout real you either (1) keep access tokens short-lived and revoke the long-lived refresh token server-side, so the user can't mint new access tokens, or (2) maintain a denylist (e.g. the token's jti in Redis with a TTL equal to its remaining lifetime) that a filter checks on every request. You implement the revocation as a custom LogoutHandler added via the logout() DSL. It's a fundamental stateless-vs-stateful tradeoff, not a Spring bug.

code

java · 19 lines
java
@Bean
SecurityFilterChain api(HttpSecurity http, JwtDenylist denylist,
                        RefreshTokenStore refreshStore) throws Exception {
    http
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
        .logout(logout -> logout
            .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())
            .addLogoutHandler((req, res, auth) -> {
                if (auth instanceof JwtAuthenticationToken jwt) {
                    var token = jwt.getToken();
                    denylist.add(token.getId(),                       // jti
                        Duration.between(Instant.now(), token.getExpiresAt()));
                }
                if (auth != null) refreshStore.revokeFor(auth.getName());
            }));
    return http.build();
}
// A JwtDecoder/OAuth2TokenValidator then rejects any jti found in the denylist.

go deeper

for a junior

Understand a JWT keeps working until it expires; server logout doesn't cancel it.

for a middle

Explain no-session means SecurityContextLogoutHandler has nothing to invalidate.

for a senior

Compare short-token+refresh-revocation vs denylist and implement via a custom LogoutHandler.

for a principal

Frame the statelessness-vs-revocation tradeoff, TTL-bounded denylists, refresh rotation, and OIDC back-channel logout for external IdPs.

## The core problem Spring Security's built-in logout is designed for **stateful, session-based** authentication. The default `SecurityContextLogoutHandler` does two things — clear the `SecurityContextHolder` and invalidate the `HttpSession`. Both operate on **server-side state**. A **stateless JWT** setup (typically `oauth2ResourceServer().jwt()` or a custom bearer-token filter) has **no session**. Every request carries a **JSON Web Token** — a signed, self-describing credential. The server authenticates a request by: 1. Verifying the **signature** (with the issuer's key), and 2. Checking the **claims**, chiefly `exp` (expiry). Nothing is looked up in server memory. Consequently: - Clearing the `SecurityContext` only affects the *current* request thread — the next request rebuilds it from the token. - Invalidating a session is a no-op because there is no session. So a user who 'logs out' can keep using any saved copy of the token until it **naturally expires**. The token is a bearer credential: possession = access. ## Why you can't just 'revoke a JWT' The whole point of stateless JWTs is avoiding a per-request server lookup. True revocation *requires* a lookup — which reintroduces state. That tension is the design decision, not a framework flaw. ## Design options ### 1. Short access token + revocable refresh token (recommended) - **Access token:** short TTL (e.g. 5–15 min), never checked against a denylist — accept the small window. - **Refresh token:** long-lived, stored server-side (DB/Redis). Logout **deletes/rotates** the refresh token so no new access tokens can be minted. - Net effect: after logout the user is fully cut off within one access-token lifetime. ### 2. Access-token denylist (blocklist) - On logout, store the token's **`jti`** (unique token id) in a fast store (Redis) with a **TTL equal to the token's remaining lifetime** (so the entry auto-expires when the token would have anyway). - A filter/`OAuth2TokenValidator` checks each incoming token's `jti` against the denylist and rejects hits. - Cost: a per-request lookup — you've traded some statelessness for immediate revocation. ### 3. Token version / 'not-before' per user - Store a `tokensValidAfter` timestamp per user; logout bumps it. Reject tokens issued before it. Also requires a per-request user lookup. ## Wiring logout in Spring You still use the `logout()` DSL, but your work happens in a **custom `LogoutHandler`**: ```java http.logout(logout -> logout .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()) .addLogoutHandler((req, res, auth) -> { if (auth instanceof JwtAuthenticationToken jwt) { String jti = jwt.getToken().getId(); Instant exp = jwt.getToken().getExpiresAt(); denylist.add(jti, Duration.between(Instant.now(), exp)); // auto-expiring entry } refreshTokenStore.revokeFor(auth.getName()); // cut off new access tokens })); ``` And a validator/filter consults `denylist` on every request. ## Gotchas & judgment calls - **Don't denylist without a TTL** — the store would grow unbounded. - **Client must discard the token too.** Server revocation handles theft; well-behaved clients should also delete the token from storage. - **CSRF/POST:** the `/logout` endpoint should still be POST; even stateless APIs benefit from not being logout-able via a stray GET (though bearer-token APIs are less CSRF-prone than cookie-based ones). - **Cookie-stored JWT** is a middle ground: you can clear the cookie via `deleteCookies`, which stops the *browser* from sending it, but a copied token still works — same denylist caveat applies. - **OpenID Connect** offers **RP-initiated logout / back-channel logout** to invalidate the session at the identity provider — the enterprise-grade answer when an external IdP is involved. ## The interview headline Stateless logout is a **tradeoff**: you either accept a bounded window (short tokens) or reintroduce state (denylist / revocable refresh tokens). Spring gives you the `logout()` DSL and `LogoutHandler` hook to implement whichever you choose, but it can't magically revoke a self-contained token.

  • Why give the denylist entry a TTL equal to the token's remaining lifetime instead of keeping it forever?
    Once the token's own exp passes, signature/expiry validation rejects it anyway, so the denylist entry is redundant after that point. A TTL equal to the remaining lifetime lets entries auto-expire, bounding the store's size instead of letting it grow forever.
  • If access tokens are only 5 minutes long, do you even need a denylist?
    Often not. With short access tokens plus a revocable refresh token, logout revokes the refresh token so no new access tokens are issued, and the current one dies within the window. A denylist only buys you immediate (sub-window) revocation at the cost of a per-request lookup — worth it only when that window is unacceptable.

saying these in an interview costs you the question

  • Claiming Spring's logout() 'invalidates' or 'revokes' a JWT server-side.
  • Thinking clearing the SecurityContext stops a saved token from working.
  • Proposing a denylist with no TTL (unbounded growth).
  • Assuming stateless APIs need no logout handling at all.

context