Spring Boot
Session Management
Spring Security manages HTTP sessions through HttpSessionSecurityContextRepository and SessionManagementFilter. Session configuration controls creation policy, fixation protection, concurrent session limits, and timeout. For stateless REST APIs, sessions are disabled entirely. For stateful applications, sessions can be stored in Redis using Spring Session for horizontal scalability. This entry covers all session strategies, concurrent session control, timeout configuration, and Redis-backed sessions.
Session Creation Policies
SessionCreationPolicy controls when Spring Security creates an HTTP session. STATELESS disables sessions entirely — the right choice for REST APIs using JWT. ALWAYS creates a session on every request. IF_REQUIRED creates one only when needed (Spring Security default). NEVER never creates a session but uses one if it already exists.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// ── STATELESS — REST APIs with JWT (no session at all) ────────────
@Bean
public SecurityFilterChain statelessFilterChain(HttpSecurity http)
throws Exception {
return http
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
// ── IF_REQUIRED — stateful web applications (default) ────────────
@Bean
public SecurityFilterChain statefulFilterChain(HttpSecurity http)
throws Exception {
return http
.sessionManagement(sm -> sm
.sessionCreationPolicy(
SessionCreationPolicy.IF_REQUIRED)
// Session fixation protection — new session on login
.sessionFixation(sf -> sf.newSession())
// Max sessions per user
.maximumSessions(1)
.maxSessionsPreventsLogin(false) // expire old session
)
.build();
}
}
// ── Session policy comparison ──────────────────────────────────────────
//
// Policy Creates session? Uses existing? Use case
// ──────────────────────────────────────────────────────────────────────
// ALWAYS Always Yes Legacy apps
// IF_REQUIRED When needed Yes Default / web apps
// NEVER Never Yes Downstream services
// STATELESS Never Never REST APIs with JWTSession Fixation Protection
Session fixation attacks trick a user into authenticating with an attacker-supplied session ID. Spring Security counters this by creating a new session on login. Configure the strategy in sessionFixation(). migrateSession() (default) creates a new session and copies the attributes; newSession() creates a clean new session; none() disables protection (not recommended).
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
.sessionManagement(sm -> sm
.sessionFixation(sf ->
// ── migrateSession (default) ──────────────────────
// New session ID, existing attributes copied
sf.migrateSession()
// ── newSession ────────────────────────────────────
// New session ID, attributes NOT copied (most secure)
// sf.newSession()
// ── none — disable (not recommended) ─────────────
// sf.none()
))
.build();
}
// ── Session fixation filter behaviour ────────────────────────────────
// Before login: session id = abc123 (attacker may know this)
// User logs in: Spring creates session id = xyz789 (new, random)
// copies security attributes from abc123 to xyz789
// After login: abc123 is invalid — attacker's known ID is useless
// ── Session cookie security ───────────────────────────────────────────
@Bean
public ServletContextInitializer sessionCookieConfig() {
return ctx -> {
ctx.getSessionCookieConfig().setHttpOnly(true); // no JS access
ctx.getSessionCookieConfig().setSecure(true); // HTTPS only
ctx.getSessionCookieConfig().setName("JSESSIONID");
ctx.getSessionCookieConfig().setPath("/");
ctx.getSessionCookieConfig().setMaxAge(1800); // 30 minutes
};
}
# ── application.yml — session cookie settings ─────────────────────────
server:
servlet:
session:
cookie:
http-only: true
secure: true
same-site: strict # Lax for OAuth2 redirects
name: SESSION
max-age: 1800 # 30 minutes
timeout: 30m # server-side session timeoutConcurrent Session Control
Concurrent session control limits how many simultaneous sessions a single user can have. When the limit is exceeded, either the new login is rejected (maxSessionsPreventsLogin = true) or the oldest session is expired. A SessionRegistry tracks active sessions and enables admin session management.
Java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
// Required for SessionRegistry to track session lifecycle
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(sf -> sf.migrateSession())
.maximumSessions(1)
// false → expire oldest session when limit exceeded
// true → reject new login when limit exceeded
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry())
// Where to redirect when session is expired
.expiredUrl("/login?expired"))
.build();
}
}
// ── Admin service — inspect and manage active sessions ────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class SessionAdminService {
private final SessionRegistry sessionRegistry;
// ── List all active sessions for a user ───────────────────────────
public List<SessionInfo> getActiveSessions(String email) {
return sessionRegistry
.getAllSessions(email, false)
.stream()
.map(info -> new SessionInfo(
info.getSessionId(),
info.getLastRequest(),
info.isExpired()))
.toList();
}
// ── Force-expire all sessions for a user ──────────────────────────
public void expireAllSessions(String email) {
sessionRegistry.getAllSessions(email, false)
.forEach(SessionInformation::expireNow);
log.info("Expired all sessions for user: {}", email);
}
// ── Count active users ────────────────────────────────────────────
public long countActiveSessions() {
return sessionRegistry.getAllPrincipals()
.stream()
.mapToLong(principal ->
sessionRegistry
.getAllSessions(principal, false)
.stream()
.filter(s -> !s.isExpired())
.count())
.sum();
}
public record SessionInfo(
String sessionId,
Date lastRequest,
boolean expired
) {}
}Redis-Backed Sessions with Spring Session
Spring Session replaces the in-memory HttpSession with a Redis-backed store. All application instances share the same session store, enabling horizontal scaling and zero-downtime deploys. Sessions survive application restarts and can be inspected and invalidated from any instance.
XML
<!-- pom.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>
# ── application.yml ────────────────────────────────────────────────────
spring:
session:
store-type: redis
redis:
namespace: myapp:session # Redis key prefix
flush-mode: on-save # on-save | immediate
cleanup-cron: "0 * * * * *" # cleanup expired sessions every minute
timeout: 30m # session TTL
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
password: ${REDIS_PASSWORD:}
// ── Enable Redis HTTP session ──────────────────────────────────────────
@Configuration
@EnableRedisHttpSession(
maxInactiveIntervalInSeconds = 1800, // 30 minutes
redisNamespace = "myapp:session",
flushMode = FlushMode.ON_SAVE
)
public class SessionConfig {
// ── JSON serializer for session attributes ────────────────────────
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY);
return new GenericJackson2JsonRedisSerializer(mapper);
}
}
// ── Controller using Redis-backed session ─────────────────────────────
@RestController
@RequestMapping("/api/v1/session")
public class SessionController {
@PostMapping("/login")
public ResponseEntity<UserResponse> login(
@RequestBody @Valid LoginRequest request,
HttpSession session) {
UserResponse user = authService.authenticate(request);
session.setAttribute("userId", user.id());
session.setAttribute("email", user.email());
session.setAttribute("loginTime", Instant.now().toString());
session.setMaxInactiveInterval(1800);
// Spring Session writes to Redis automatically
return ResponseEntity.ok(user);
}
@GetMapping("/info")
public ResponseEntity<SessionInfoResponse> info(
HttpSession session) {
return ResponseEntity.ok(new SessionInfoResponse(
session.getId(),
session.getCreationTime(),
session.getLastAccessedTime(),
session.getMaxInactiveInterval()
));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpSession session) {
session.invalidate(); // removes from Redis
return ResponseEntity.noContent().build();
}
}Session Timeout and Expiry Handling
Configure session timeout at the server level and handle expired session responses gracefully. For REST APIs, return 401 JSON when a session expires rather than redirecting to a login page. Use session event listeners to trigger cleanup logic when sessions are created, destroyed, or expired.
Java
# ── application.yml — server-level timeout ────────────────────────────
server:
servlet:
session:
timeout: 30m # server-side inactivity timeout
cookie:
max-age: 3600 # browser cookie lifetime (should be >= timeout)
// ── Expired session response handler ─────────────────────────────────
@Component
@RequiredArgsConstructor
public class ExpiredSessionStrategy
implements InvalidSessionStrategy {
private final ObjectMapper objectMapper;
@Override
public void onInvalidSessionDetected(
HttpServletRequest request,
HttpServletResponse response)
throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(),
Map.of(
"status", 401,
"error", "Session Expired",
"message", "Your session has expired. Please log in again.",
"path", request.getRequestURI()
));
}
}
// ── Register the expired session strategy ────────────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
ExpiredSessionStrategy expiredStrategy) throws Exception {
return http
.sessionManagement(sm -> sm
.invalidSessionStrategy(expiredStrategy)
.maximumSessions(1)
.expiredSessionStrategy(event -> {
HttpServletResponse response =
event.getResponse();
response.setStatus(
HttpStatus.UNAUTHORIZED.value());
response.setContentType(
MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
"{"error":"Session expired"," +
""message":"Concurrent login detected."}");
}))
.build();
}
// ── Session lifecycle event listeners ────────────────────────────────
@Component
@Slf4j
public class SessionLifecycleListener
implements HttpSessionListener {
private final AtomicInteger activeSessions = new AtomicInteger(0);
@Override
public void sessionCreated(HttpSessionEvent event) {
int count = activeSessions.incrementAndGet();
log.debug("Session created: {} (total: {})",
event.getSession().getId(), count);
}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
int count = activeSessions.decrementAndGet();
log.debug("Session destroyed: {} (total: {})",
event.getSession().getId(), count);
}
public int getActiveSessionCount() {
return activeSessions.get();
}
}
// ── Register listener (required when not using Spring Session) ────────
@Bean
public ServletListenerRegistrationBean<SessionLifecycleListener>
sessionListener(SessionLifecycleListener listener) {
return new ServletListenerRegistrationBean<>(listener);
}Stateless vs Stateful Comparison
Choose the session strategy based on the application type. REST APIs consumed by mobile apps and SPAs should be stateless with JWT. Server-rendered web applications and applications that need server-side session invalidation benefit from stateful sessions, optionally backed by Redis.
Java
// ── Stateless REST API configuration (JWT) ────────────────────────────
@Bean
public SecurityFilterChain statelessChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfig()))
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
// ── Stateful web application configuration (Redis session) ────────────
@Bean
public SecurityFilterChain statefulChain(HttpSecurity http)
throws Exception {
return http
.csrf(csrf -> csrf
.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse()))
.sessionManagement(sm -> sm
.sessionCreationPolicy(
SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(sf -> sf.migrateSession())
.maximumSessions(3)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard"))
.logout(logout -> logout
.logoutUrl("/logout")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("SESSION"))
.build();
}
// ── Decision guide ─────────────────────────────────────────────────────
//
// Stateless (JWT) Stateful (Session)
// ─────────────────────────────────────────────────────────────────
// REST APIs / microservices Server-rendered web apps
// Mobile app backends Admin panels
// SPA backends Internal tools
// Multi-region deployments Apps needing instant revocation
// Horizontal scaling easy Requires shared session store (Redis)
// No server-side revocation Server-side revocation supported
// Short-lived tokens (15m) Long-lived sessions (30m+)