HomeSpring BootSpring Security Session Management: Policies, Fixation, Concurrency and Redis
Spring Boot

Spring Security Session Management: Policies, Fixation, Concurrency and Redis

Configure session management in Spring Boot 3 and Spring Security 6: SessionCreationPolicy, session fixation protection, concurrent session limits, secure cookies, timeouts, and Redis-backed sessions with Spring Session. Includes troubleshooting for common session problems.

How Spring Security Handles Sessions

Spring Security stores the authenticated `SecurityContext` in the HTTP session and looks it up on each request. Session behaviour is configured with `http.sessionManagement(...)`: when sessions are created, how fixation attacks are prevented, how many concurrent sessions a user may have, and what happens when a session expires. Two points that changed in Spring Security 6: - **Explicit save.** The `SecurityContext` is no longer saved to the session automatically. Spring Security's own login flows save it for you, but a custom login controller must save the context through a `SecurityContextRepository`. - **Authentication strategies.** Session-fixation and concurrency strategies are applied by the authentication filter at login time. The old `SessionManagementFilter` is not applied by default. Examples target Spring Boot 3.2+ (Spring Security 6.1+). `sessionConcurrency(...)` and `sessionFixation(...)` with a lambda need Security 6.1 or later.

Stateless vs Stateful: Which Should You Use?

| | Stateless (JWT) | Stateful (HTTP session) | |---|---|---| | Typical use | REST APIs, mobile and SPA backends, microservices | Server-rendered apps, admin panels, internal tools | | Server-side revocation | Hard (needs a token denylist) | Easy: invalidate the session | | Horizontal scaling | Easy, nothing to share | Needs sticky sessions or a shared store (Redis) | | CSRF protection needed | No, when credentials are sent in a header | Yes, because the browser sends the cookie automatically | | Credential lifetime | Short-lived tokens plus refresh | Sliding inactivity timeout | Neither is "better". Pick by client type and whether you need instant logout or revocation.

Session Creation Policies

`SessionCreationPolicy` controls when Spring Security creates a session. | Policy | Creates a session | Uses an existing one | Use case | |---|---|---|---| | `ALWAYS` | On every request | Yes | Legacy apps | | `IF_REQUIRED` (default) | When needed, e.g. after login | Yes | Server-rendered web apps | | `NEVER` | Never | Yes | Apps behind another component that creates sessions | | `STATELESS` | Never | Never | REST APIs with JWT | `STATELESS` only controls Spring Security. Your own code (or another library) can still create a session by calling `request.getSession()`.

Separate Chains for an API and a Web App

An application often needs both: a stateless `/api/**` and a stateful web UI. Define one `SecurityFilterChain` per area, each with a `securityMatcher` and an `@Order`. Without a matcher, the first chain matches every request, and Spring Security fails at startup because the later chain is unreachable. Disabling CSRF is acceptable on the API chain only because it authenticates with a header token and sets no session cookie. Keep CSRF enabled on the web chain.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    SecurityFilterChain apiChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter)
            throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain webChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/register").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard"))
            .logout(logout -> logout
                .logoutUrl("/logout")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID"))   // "SESSION" when using Spring Session
            .build();
    }
}

Session Fixation Protection

In a session fixation attack, the attacker gets a victim to authenticate using a session ID the attacker already knows. Spring Security prevents this by issuing a new session ID at login. **Protection is enabled by default**, so you only configure it to change the strategy. | Strategy | Behaviour | |---|---| | `changeSessionId()` (default on Servlet 3.1+) | Keeps the session and attributes, changes only the ID | | `migrateSession()` | Creates a new session and copies the attributes over | | `newSession()` | Creates a clean session; existing attributes are not copied (Spring Security's own attributes are kept) | | `none()` | No protection. Don't use it. | For most applications the default is the right choice.
Java
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .sessionManagement(sm -> sm
            .sessionFixation(sf -> sf.changeSessionId()))   // the default, shown explicitly
        .build();
}

// Before login:  session id = abc123 (an attacker may know this)
// After login:   session id = xyz789 (new, random)
//                abc123 is no longer valid

Session Cookie Security and Timeout

Configure the cookie and the inactivity timeout with properties. You don't need a `ServletContextInitializer`. - **HttpOnly** hides the cookie from JavaScript. - **Secure** sends it only over HTTPS. It breaks local development over plain HTTP, so set it per profile. - **SameSite** limits cross-site sending. `strict` can break flows that start on another site, such as an OAuth2 redirect back to your app. Use `lax` there. - **Don't set `max-age`** unless you want a persistent cookie. Without it, the cookie lives until the browser closes, and the server-side `timeout` decides when the session expires. - `server.servlet.session.timeout` is an inactivity timeout, and the minimum is one minute. - When Spring Session is on the classpath, `spring.session.timeout` takes precedence over the server timeout.
yaml
# application.yml
server:
  servlet:
    session:
      timeout: 30m
      cookie:
        http-only: true
        secure: true
        same-site: lax

# application-dev.yml: plain HTTP locally
server:
  servlet:
    session:
      cookie:
        secure: false

Concurrent Session Control

Limit how many simultaneous sessions one user may have. When the limit is exceeded, either the **oldest session is expired** (`maxSessionsPreventsLogin(false)`, the default) or the **new login is rejected** (`true`). You need an `HttpSessionEventPublisher` bean so the registry learns when sessions are destroyed. Without it, expired sessions keep counting toward the limit. **Your `UserDetails` class must implement `equals` and `hashCode`** (based on the username). The registry keys sessions by principal, so without them the same user looks like a different principal on each login and the limit never triggers. Spring's built-in `User` class already does this.
Java
@Bean
SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}

@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}

@Bean
SecurityFilterChain filterChain(HttpSecurity http, SessionRegistry sessionRegistry)
        throws Exception {
    return http
        .sessionManagement(sm -> sm
            .sessionConcurrency(c -> c
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false)    // expire the oldest session
                .sessionRegistry(sessionRegistry)
                .expiredUrl("/login?expired")))
        .build();
}

Inspecting and Expiring Sessions (Admin Features)

`SessionRegistry` lets you list a user's sessions or force them to log out. The registry is keyed by the **principal object**, not by a username string, so `getAllSessions("alice@x.com", false)` finds nothing when the principal is a `UserDetails`. Walk the principals and match by username instead. `SessionRegistryImpl` only knows about sessions on the **current instance**. In a cluster, use the Redis-backed registry in the next sections.
Java
@Service
public class SessionAdminService {

    private static final Logger log = LoggerFactory.getLogger(SessionAdminService.class);

    private final SessionRegistry sessionRegistry;

    public SessionAdminService(SessionRegistry sessionRegistry) {
        this.sessionRegistry = sessionRegistry;
    }

    public List<SessionSummary> activeSessions(String username) {
        return sessionsFor(username).stream()
            .map(s -> new SessionSummary(s.getSessionId(), s.getLastRequest(), s.isExpired()))
            .toList();
    }

    public void expireAllSessions(String username) {
        sessionsFor(username).forEach(SessionInformation::expireNow);
        log.info("Expired all sessions for user {}", username);
    }

    private List<SessionInformation> sessionsFor(String username) {
        return sessionRegistry.getAllPrincipals().stream()
            .filter(p -> p instanceof UserDetails u && u.getUsername().equals(username))
            .flatMap(p -> sessionRegistry.getAllSessions(p, false).stream())
            .toList();
    }

    public record SessionSummary(String sessionId, Date lastRequest, boolean expired) { }
}

Redis-Backed Sessions with Spring Session

Spring Session stores sessions in Redis instead of server memory. All instances share one session store, so you can scale horizontally and deploy without logging everyone out. In Spring Boot, adding the two dependencies is enough. Boot auto-configures Spring Session and the `spring.session.*` properties control it. **You don't need `@EnableRedisHttpSession`**, and adding it overrides those properties. The session cookie is named `SESSION` by default, not `JSESSIONID`. An older `spring.session.store-type` property appears in many tutorials. Recent Boot versions detect the store from the classpath, so check your version's reference before using it.
XML
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Spring Session Configuration

Set the namespace, flush mode and timeout in properties. `on-save` (the default) writes changes at the end of the request, and `immediate` writes on every change.
yaml
# application.yml
spring:
  session:
    timeout: 30m                  # session TTL; overrides server.servlet.session.timeout
    redis:
      namespace: myapp:session    # Redis key prefix
      flush-mode: on-save
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: 6379
      password: ${REDIS_PASSWORD:}

Serializing Session Data

By default, Spring Session serializes with Java serialization, so every session attribute must be `Serializable`. Changing a class between deployments can then break deserialization of existing sessions. To store JSON instead, define a `springSessionDefaultRedisSerializer` bean built with **Spring Security's Jackson modules**, which whitelist the security types. Don't enable `activateDefaultTyping` with a permissive validator, because that allows arbitrary class deserialization from Redis. Check this against the Spring Security version you use, since the Jackson module setup changes between major versions.
Java
@Configuration
public class SessionConfig implements BeanClassLoaderAware {

    private ClassLoader loader;

    @Bean
    RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModules(SecurityJackson2Modules.getModules(this.loader));
        return new GenericJackson2JsonRedisSerializer(mapper);
    }

    @Override
    public void setBeanClassLoader(ClassLoader classLoader) {
        this.loader = classLoader;
    }
}

Concurrent Sessions with Spring Session

With Redis-backed sessions, use `SpringSessionBackedSessionRegistry` so the session limit is enforced across all instances. It reads from the Redis session repository instead of local memory. Limitation: this registry does not support listing all principals (`getAllPrincipals()`), so the admin "count everyone" helper from the earlier section does not work with it. Look up one user's sessions by name instead.
Java
@Bean
SpringSessionBackedSessionRegistry<? extends Session> sessionRegistry(
        FindByIndexNameSessionRepository<? extends Session> repository) {
    return new SpringSessionBackedSessionRegistry<>(repository);
}

@Bean
SecurityFilterChain filterChain(HttpSecurity http, SessionRegistry sessionRegistry)
        throws Exception {
    return http
        .sessionManagement(sm -> sm
            .sessionConcurrency(c -> c
                .maximumSessions(1)
                .sessionRegistry(sessionRegistry)))
        .build();
}

Using the Session in Your Code

Inject `HttpSession` to store per-user state such as a shopping cart. With Spring Session, changes are written to Redis when the request finishes. **Call `setAttribute` again after changing a stored object**, because mutating the object alone is not detected. Don't create your own login endpoint that only sets attributes like `userId`. That doesn't authenticate the user. Use Spring Security's `formLogin` or an `AuthenticationManager`, which handle fixation protection and the `SecurityContext` for you.
Java
@RestController
@RequestMapping("/cart")
public class CartController {

    @PostMapping("/items")
    public ResponseEntity<Void> addItem(@RequestBody @Valid AddItemRequest request,
                                        HttpSession session) {
        Cart cart = (Cart) session.getAttribute("cart");
        if (cart == null) {
            cart = new Cart();               // Cart must implement Serializable
        }
        cart.add(request.productId(), request.quantity());
        session.setAttribute("cart", cart);  // re-set so Spring Session saves the change
        return ResponseEntity.noContent().build();
    }

    @GetMapping
    public ResponseEntity<Cart> view(HttpSession session) {
        Cart cart = (Cart) session.getAttribute("cart");
        return cart == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(cart);
    }
}

Handling Expired Sessions in a REST API

By default, an expired or invalid session redirects to the login page, which is wrong for a JSON API. Return `401` with a JSON body instead. - `invalidSessionStrategy` runs when the request carries a session ID that no longer exists. - `expiredSessionStrategy` runs when a session was expired by the concurrent-session limit. Build the body with `ObjectMapper` instead of concatenating JSON strings.
Java
@Component
public class JsonSessionStrategies {

    private final ObjectMapper objectMapper;

    public JsonSessionStrategies(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public InvalidSessionStrategy invalidSession() {
        return (request, response) ->
            write(response, "Session Expired", "Your session has expired. Please log in again.");
    }

    public SessionInformationExpiredStrategy expiredSession() {
        return event ->
            write(event.getResponse(), "Session Expired", "You were signed out by a login elsewhere.");
    }

    private void write(HttpServletResponse response, String error, String message)
            throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(response.getWriter(),
            Map.of("status", 401, "error", error, "message", message));
    }
}

// In the filter chain:
.sessionManagement(sm -> sm
    .invalidSessionStrategy(strategies.invalidSession())
    .sessionConcurrency(c -> c
        .maximumSessions(1)
        .expiredSessionStrategy(strategies.expiredSession())))

Listening to Session Events

Declare an `HttpSessionListener` as a `@Component`. Spring Boot registers it with the servlet container automatically, so **don't also add a `ServletListenerRegistrationBean`**, which registers it a second time and double-counts every session. An in-memory counter like this only reflects sessions on one instance. With Spring Session, container-level events work differently, so listen to Spring Session's own events (`SessionCreatedEvent`, `SessionExpiredEvent`, `SessionDeletedEvent`) through an `@EventListener` instead.
Java
@Component
public class SessionLifecycleListener implements HttpSessionListener {

    private static final Logger log = LoggerFactory.getLogger(SessionLifecycleListener.class);
    private final AtomicInteger active = new AtomicInteger();

    @Override
    public void sessionCreated(HttpSessionEvent event) {
        log.debug("Session created (active: {})", active.incrementAndGet());
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent event) {
        log.debug("Session destroyed (active: {})", active.decrementAndGet());
    }
}

Troubleshooting Session Problems

| Symptom | Usual cause | Fix | |---|---|---| | Login succeeds but the next request is anonymous | Custom login code didn't save the `SecurityContext` (explicit save in Security 6) | Save it through a `SecurityContextRepository`, or use `formLogin` | | Startup error about an unreachable filter chain | Two chains without `securityMatcher` and `@Order` | Add a matcher and order to each chain | | Users logged out after every deploy | Sessions held in server memory | Use Spring Session with Redis | | Session limit never triggers | Missing `HttpSessionEventPublisher`, or `UserDetails` lacks `equals` and `hashCode` | Add the bean; implement both methods on the username | | Cookie not sent from a browser app on another domain | `SameSite` too strict, or credentials not allowed in CORS | Configure CORS with credentials; cross-site cookies need `SameSite=None` and `Secure` | | Cookie never set in local development | `cookie.secure=true` over plain HTTP | Disable it in the `dev` profile | | `SerializationException` or `ClassCastException` reading sessions | Attribute isn't `Serializable`, or a class changed between deployments | Make it `Serializable`, or switch to JSON serialization | | Changes to a session object disappear | Mutated the object without calling `setAttribute` again | Re-set the attribute after changing it | | Redirect to the login page instead of a 401 JSON | Default invalid-session behaviour | Configure `invalidSessionStrategy` | | Session still active after logout | Logout didn't invalidate the session, or the cookie wasn't cleared | Use the built-in logout with `invalidateHttpSession(true)` |

Common Mistakes

- Defining two `SecurityFilterChain` beans without `securityMatcher` and `@Order`. - Disabling session fixation protection. - Disabling CSRF on a chain that still authenticates with cookies. - Setting `cookie.max-age` and turning the session cookie into a long-lived persistent one. - Forgetting `HttpSessionEventPublisher`, so expired sessions keep counting against the limit. - Using the in-memory `SessionRegistryImpl` in a multi-instance deployment. - Using `activateDefaultTyping` with a permissive validator for Redis serialization. - Building your own login endpoint that only sets session attributes. - Storing large objects in the session. Keep sessions small, because they are read and written on every request.

Which Session Setup Should You Choose?

- **REST API for mobile or SPA clients:** `STATELESS` with JWT, and see the JWT pages for refresh tokens - **Server-rendered app on a single instance:** the default `IF_REQUIRED`, fixation protection on, secure cookie - **Server-rendered app on several instances:** Spring Session with Redis, and `SpringSessionBackedSessionRegistry` if you limit sessions - **Must log users out instantly or "sign out everywhere":** server-side sessions, not stateless tokens - **Both an API and a web UI:** two filter chains with matchers, as shown above