The problem: "we use JWT because it is stateless"

A team builds a web application with a single-page front end and a Spring Boot API. Someone says sessions do not scale and JWTs are the modern choice, so the front end stores a JWT in localStorage with a 30-day expiry. Months later a user reports a stolen laptop. The team discovers that there is no way to end that user's access before the token expires, and that any script injected into the page can read the token.

Neither approach is universally better. They move state and risk to different places. The goal of this article is to make those moves visible so that you can choose on purpose.

Two models

Server-side session. After login the server creates a session record (in memory, or in a shared store such as Redis or a database) and sends the browser an opaque identifier in a cookie. Each request carries the cookie; the server looks up the session and finds the user. The identifier means nothing without the server's record.

Stateless token. After login the server issues a signed token, commonly a JWT, that contains claims such as the subject, roles and an expiry. The client sends it in the Authorization: Bearer header. The server verifies the signature and expiry and trusts the claims, with no lookup.

Session:   browser --cookie(id)--> server --lookup--> session store
Token:     client --Bearer(JWT)--> server --verify signature--> claims

A JWT is a signed token, not an encrypted one. Anyone holding it can read the payload, so it must not contain secrets or unneeded personal data.

Revocation

This is the decisive difference.

  • With a session, logout, password change or "sign out everywhere" means deleting records. The effect is immediate.
  • With a self-contained JWT, the server has no state to delete. The token stays valid until its exp claim passes. To revoke earlier, you must reintroduce state: a deny list of token identifiers (jti), a per-user "tokens valid after" timestamp, or short expiry combined with refresh tokens that you can revoke.

Every one of these re-adds a lookup, so the claim "no server state" holds only until you need revocation. The usual compromise is a short-lived access token (minutes) and a longer-lived refresh token that is stored server-side and can be revoked.

Refresh tokens and rotation

The refresh token is exchanged for new access tokens. Because it lives longer, it is the more valuable target. Good practice:

  1. Store refresh tokens on the server, hashed (like passwords, but fast hashing is acceptable because they are high-entropy random values).
  2. Rotate them: each use returns a new refresh token and invalidates the old one.
  3. Detect reuse: if an already-used refresh token appears again, assume theft and revoke the whole token family for that login.
  4. Bind them to a device or session record so a user can see and end sessions.
// Sketch: refresh endpoint logicRefreshRecord stored = refreshRepo.findByHash(hash(presented))        .orElseThrow(InvalidTokenException::new);if (stored.isUsed()) {                       // reuse detected    refreshRepo.revokeFamily(stored.familyId());    throw new InvalidTokenException();}stored.markUsed();return tokenService.issue(stored.userId(), stored.familyId());   // new pair

Where does the client keep it?

For browsers, there are two main choices and both have costs.

  • Cookie with HttpOnly, Secure and a suitable SameSite value: JavaScript cannot read it, so an injected script cannot steal it directly. But the browser sends cookies automatically, which creates CSRF exposure. Defend with SameSite, CSRF tokens (Spring Security enables CSRF protection by default for session-based setups) and checking Origin.
  • localStorage or JavaScript memory with a bearer header: not sent automatically, so classic CSRF does not apply. But any XSS can read it from storage. Memory-only storage reduces persistence but is lost on reload, so apps often pair it with a refresh cookie.

Neither choice removes the need to prevent XSS: with an injected script, an attacker can make authenticated requests from inside the page even if the token itself is unreadable.

A pragmatic default

For a first-party web app, where your own front end and API share a site, use server-side sessions with a secure cookie, or the "backend for frontend" pattern where the server holds tokens and the browser holds only a session cookie. You get immediate revocation, small attack surface in the browser and mature framework support (spring-session with Redis if you scale to several instances). Keep CSRF protection on.

For a mobile app or third-party API client, cookies fit poorly. Use short-lived access tokens and rotating refresh tokens, store them in the platform's secure storage (Keychain or Keystore), and make revocation possible through server-side refresh records. If you integrate with an external identity provider, use OAuth 2.0 / OpenID Connect flows with Spring Security's resource server support rather than inventing your own token format.

For service-to-service calls inside your system, signed tokens with narrow scopes and short expiry are natural, because there is no browser involved.

Configuring a resource server

@BeanSecurityFilterChain api(HttpSecurity http) throws Exception {    return http            .csrf(csrf -> csrf.disable())          // acceptable only for header-based bearer auth            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))            .authorizeHttpRequests(a -> a                    .requestMatchers("/auth/**").permitAll()                    .anyRequest().authenticated())            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))            .build();}

Disabling CSRF is justified here only because credentials are not sent automatically by the browser. If you also accept cookies on the same API, that reasoning no longer holds.

When validating a JWT, always check the signature with a key you control, the expiry, the issuer and the audience. Pin the accepted algorithm and never accept none. Rotate signing keys and publish them through a key set so verifiers can pick up new keys.

Common mistakes

  1. Storing long-lived access tokens (days or weeks) with no revocation path.
  2. Putting sensitive data in the payload because it is "inside a token".
  3. Using the same secret for signing in every environment, or committing it to the repository.
  4. Not validating iss and aud, so a token meant for another service is accepted.
  5. Treating the JWT roles claim as always fresh: after a role change the old token still carries the old roles until expiry.
  6. Disabling CSRF globally while still using cookie-based login.
  7. Rolling your own token format or crypto instead of well-reviewed libraries.

Trade-offs

AspectServer sessionStateless JWT
RevocationImmediateNeeds extra state or short expiry
ScalingNeeds shared store when scaled outNo lookup per request
Browser storageHttpOnly cookieCookie or script-accessible storage
CSRFMust be handledNot for header-only bearer
Cross-service useAwkwardNatural
Operational simplicityStore to run and monitorKey management and rotation

Checklist

  • Decide the client types first: browser, mobile, other services.
  • Access tokens are short-lived; refresh tokens are rotated and revocable.
  • Cookies are HttpOnly, Secure, with a deliberate SameSite value.
  • CSRF protection matches the credential transport.
  • Issuer, audience, expiry and signature are validated.
  • Logout and password change invalidate server-side state.

Where to go next