What is Session Management — and Why Does it Exist?
HTTP is stateless — every request arrives with no memory of the previous one. A session is the mechanism that lets a server recognize "this request belongs to the same authenticated user as the last one," so a customer doesn't have to re-enter credentials on every page of a checkout flow. The server generates a session identifier, hands it to the client as a cookie, and keeps the actual authenticated state — who this is, what's in their cart, what roles they have — on the server side, keyed by that identifier.
This page is about the security engineering around that mechanism, not
the cookie attributes themselves — Secure,
HttpOnly, and SameSite are covered in depth on
Sessions & Cookies.
What belongs here is what goes wrong when session identifiers are
predictable, when they aren't regenerated at the moment of login, when
logout doesn't actually destroy server-side state, and when a stateful
session model needs to survive more than one server instance.
// BEFORE — a hand-rolled, predictable session identifier
String sessionId = customer.getId() + "-" + System.currentTimeMillis();
// An attacker who knows (or can enumerate) a customer ID and a rough login
// time can construct valid session identifiers directly — no theft required,
// just guessing within a narrow, predictable search space.
// AFTER — Spring Security's container-managed session, high-entropy by default
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId()) // new ID issued on login
)
.build();
// The session ID itself is a cryptographically random token generated by the
// servlet container (SecureRandom-backed) — nothing about it is derivable
// from the user's identity, login time, or any other guessable value.
}
Session Identifier Entropy — Why It Must Be Unguessable, Not Just Unique
A session ID has exactly one job from a security standpoint: an attacker
who doesn't already have it must not be able to compute or predict it. A
UUID is unique, but not every UUID generation strategy is
cryptographically random — version 1 UUIDs embed a timestamp and MAC
address and are absolutely not appropriate as a session identifier.
Servlet containers (Tomcat, and by extension Spring Boot's embedded
server) generate session IDs from SecureRandom with enough
bits of entropy that brute-forcing one is computationally infeasible —
this is not something application code needs to implement, only
something it must not accidentally override with something weaker.
The session ID is a lookup key, nothing more. All of the actual state — user identity, roles, cart contents — belongs in the server-side session store, retrieved by that key. If any of that information leaks into the identifier itself (encoded, encrypted, or otherwise), you've built a bearer token with extra steps, and lost the primary advantage of a session model: the ability to instantly invalidate it server-side without the client's cooperation.
Session Fixation — The Attack That Regeneration Defeats
Session fixation works by having the attacker set (or simply know) a session ID before the victim authenticates. If the application keeps using that same session ID after login — merely upgrading it from anonymous to authenticated in the store — the attacker, who already holds that ID, is now authenticated as the victim too.
/*
* The attack, step by step:
*
* 1. Attacker visits the login page, receives session ID "ABC123" (unauthenticated).
* 2. Attacker tricks the victim into using a link that carries that same ID
* (e.g. https://shop.example.com/login;jsessionid=ABC123 on a container
* that accepts session IDs via URL rewriting).
* 3. Victim logs in. If the application reuses "ABC123" as-is, it is now an
* AUTHENTICATED session under that identifier.
* 4. Attacker, who already has "ABC123", is now logged in as the victim —
* no credential theft occurred at all, only identifier reuse.
*/
// THE FIX: issue a brand-new session ID at the moment of authentication,
// regardless of whether one already existed
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId())
// changeSessionId() is Spring Security's DEFAULT since 4.x — this is shown
// explicitly for clarity, not because it needs to be added by hand.
// migrateSession() and newSession() are the older, JEE-container-level
// alternatives; changeSessionId() is preferred because it preserves
// session attributes instead of discarding them.
)
.build();
}
Invalidation — Why Deleting the Cookie is Not the Same as Logging Out
Clearing a cookie on the client only stops that browser from sending the identifier again — it does nothing to the session state still sitting in the server's store. If an attacker captured the session ID before the legitimate user "logged out" client-side, that captured ID remains valid and authenticated until the server-side entry is explicitly destroyed or expires on its own.
// Correct logout: invalidate server-side state, THEN clear the client cookie
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.logout(logout -> logout
.logoutUrl("/api/auth/logout")
.invalidateHttpSession(true) // destroys the server-side session entry
.clearAuthentication(true) // clears the SecurityContext for this request
.deleteCookies("JSESSIONID") // tells the browser to stop sending it too
)
.build();
}
// Password change is a security-sensitive event that should kill EVERY OTHER
// session for that user, not just the current one — otherwise a session an
// attacker already hijacked survives the victim's own password change.
@Service
public class PasswordChangeService {
private final SessionRegistry sessionRegistry;
private final CustomerRepository customerRepository;
private final PasswordEncoder passwordEncoder;
public PasswordChangeService(SessionRegistry sessionRegistry,
CustomerRepository customerRepository,
PasswordEncoder passwordEncoder) {
this.sessionRegistry = sessionRegistry;
this.customerRepository = customerRepository;
this.passwordEncoder = passwordEncoder;
}
public void changePassword(String username, String newRawPassword) {
Customer customer = customerRepository.findByUsername(username).orElseThrow();
customer.setPasswordHash(passwordEncoder.encode(newRawPassword));
customerRepository.save(customer);
// Expire every registered session for this principal — including any
// session an attacker may currently hold without the user's knowledge
sessionRegistry.getAllSessions(username, false)
.forEach(SessionInformation::expireNow);
}
}
Concurrent Session Control and Timeout Policy
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId())
.maximumSessions(1) // one active session per account at a time
.maxSessionsPreventsLogin(false) // false = new login WINS, kicks out the old session
.expiredUrl("/login?expired") // where the kicked-out session gets redirected
)
.build();
}
| Policy | Controls | Typical setting |
|---|---|---|
| Idle timeout | Session expires after N minutes of no activity | server.servlet.session.timeout=30m for a typical customer-facing app |
| Absolute timeout | Session expires N hours after login, regardless of activity | Enforced in application code (compare against a stored login timestamp) — not exposed as a single container property |
| Concurrent session limit | How many simultaneous sessions one account may hold | maximumSessions(1) for banking-grade apps; higher or unlimited for typical e-commerce, where a customer legitimately shops from phone and laptop at once |
Idle timeout alone means a session left open in an actively-used background tab — the classic shared-computer scenario — never expires as long as some background request keeps resetting the idle clock. An absolute timeout guarantees re-authentication is required periodically no matter how active the session appears, which matters more for account settings or payment-method changes than for continuing to browse a product catalog.
Scaling Sessions — Why In-Memory Storage Breaks Beyond One Instance
The default servlet session store lives in the JVM's heap. That's fine for a single instance, but it breaks the moment you run more than one: a load balancer without sticky sessions can route request 2 of the same user to an instance that has never seen their session ID, and the user is unexpectedly logged out. Sticky sessions "solve" this by pinning a user to one instance — at the cost of uneven load distribution and total session loss if that specific instance restarts or is scaled down.
// Spring Session backed by Redis: sessions live in a shared, external store
// that every application instance reads from and writes to identically —
// no stickiness required, and a rolling deploy doesn't log anyone out.
// build.gradle / pom.xml: spring-boot-starter-data-redis + spring-session-data-redis
# application.yml
spring:
session:
store-type: redis
redis:
namespace: shop:sessions
data:
redis:
host: ${REDIS_HOST}
port: 6379
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {}
// From this point, HttpSession is transparently backed by Redis — application
// code that calls request.getSession() doesn't change at all. Instance count
// can now scale horizontally, and any instance can serve any request for any
// authenticated user.
A server-side session, wherever it's stored, gives you one
capability a bearer token fundamentally cannot on its own: instant,
server-side revocation. Delete the entry from the store — or call
SessionInformation.expireNow() as shown above — and that
session is dead everywhere, immediately, no matter how many requests
are already in flight with that identifier. The cost is coordination:
every instance needs access to the same store, and that store is now
a dependency your login flow can't function without.
JWT inverts this trade-off entirely — no
shared store, no per-request lookup, but no instant revocation
either, without additional infrastructure of its own. Neither model
is universally correct; which one fits depends on whether your
architecture needs the revocation guarantee more than it needs to
avoid a shared session store.
Best Practices and Common Pitfalls
✅ Do
- Regenerate the session identifier at the moment of authentication —
sessionFixation().changeSessionId()is Spring Security's default; keep it that way - Invalidate the server-side session on logout, not just the client-side cookie
- Expire every other active session for an account when its password changes
- Set both an idle timeout and, for sensitive actions, an absolute timeout
- Move to Spring Session with a shared store (Redis) the moment you run more than one instance behind a load balancer
- Choose a concurrent-session policy deliberately based on the product — one active session for a banking app, multiple devices for typical e-commerce
❌ Don't
- Don't construct session identifiers from predictable values (user ID, timestamp) — rely on the container's
SecureRandom-backed generation - Don't embed identity, roles, or any authoritative state inside the session identifier itself — it's a lookup key, not a payload
- Don't rely on sticky load-balancer routing as a substitute for a shared session store — it doesn't survive instance restarts or scale-down events
- Don't assume "the cookie was cleared" means "the session is dead" — verify server-side invalidation independently
- Don't leave a session store's inactive-session TTL unset — an unbounded store grows forever and never truly logs anyone out
Interview Questions
Q: What is a session, and why does HTTP need one at all?
HTTP is stateless — each request is independent and carries no memory of
previous ones. A session lets the server recognize repeated requests
from the same authenticated client by issuing a unique identifier at
login, stored client-side as a cookie, that maps to server-side state
(identity, roles, cart contents) on every subsequent request.
Q: If a user clears their session cookie, are they logged out?
Only from that browser's perspective going forward — the cookie no
longer gets sent, so the server can no longer associate that browser
with the session. But the session entry itself still exists server-side
until it's explicitly invalidated or naturally expires. If someone else
captured that session ID beforehand, clearing the cookie on the original
browser does nothing to stop them.
Q: What is session fixation?
An attack where the attacker sets or already knows a session identifier
before the victim logs in, then relies on the application reusing that
same identifier after authentication succeeds. If the ID isn't
regenerated at login, the attacker — who already holds that same ID —
becomes authenticated as the victim without ever stealing a password.
Q: A customer changes their password after noticing suspicious activity on their account, but an attacker's session remains active afterward. What went wrong, and how do you fix it architecturally?
Changing a password by itself only affects future authentication
attempts — it does nothing to sessions that are already established and
authenticated, since those sessions don't re-verify the password on
every request. The fix is to treat password change as a
security-sensitive event that explicitly expires every other active
session for that principal, using something like Spring Security's
SessionRegistry.getAllSessions(username, false) and calling
expireNow() on each. This requires the application to be
tracking sessions per-principal in the first place, which is one of the
capabilities you lose entirely if you migrate to a stateless
bearer-token model without building an equivalent revocation mechanism.
Q: You deploy behind a load balancer with three instances and no sticky sessions. Users report being randomly logged out mid-session. Diagnose and fix.
The default servlet session store is per-JVM, in-memory. Without sticky
routing, the load balancer can send consecutive requests from the same
user to different instances, and any instance that never received the
original login request has no record of that session ID — the user
appears logged out from that instance's point of view, even though the
session is perfectly valid on another one. Sticky sessions mask the
symptom but reintroduce uneven load and total session loss whenever that
specific instance restarts or is scaled down. The architectural fix is a
shared, external session store — Spring Session backed by Redis is the
standard solution — so every instance reads and writes the identical
session state regardless of which one handles a given request.
Q: What does a session-based architecture guarantee that a stateless JWT architecture does not, and what does JWT gain in exchange?
A server-side session gives you instant, authoritative revocation:
deleting the store entry (or expiring it via the session registry) ends
that session everywhere immediately, because every request must look it
up against the same shared store to be considered valid. JWT trades that
guarantee away — a token, once issued, is self-contained and verifiable
without a lookup, which removes the shared-store dependency and the
per-request I/O cost entirely, but also means a token cannot be
invalidated before its expiry without additional infrastructure (a
revocation list, short-lived tokens with refresh rotation, or similar).
Neither trade-off is universally correct — it depends on whether the
architecture values horizontal scalability without shared state more
than it values the ability to revoke access instantly.