Session Management

Session fixation, hijacking, invalidation, and the stateful/stateless trade-off that motivates everything covered on the next page, JWT

← Back to Index

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.

Never put identity or role information inside the session identifier itself

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();
}
PolicyControlsTypical 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
Absolute timeout matters even when idle timeout is generous

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.
The stateful/stateless trade-off — and why the next page exists

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

🎓 Junior level

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.

🔥 Senior level

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.