Session Management: Fixation, Concurrency, and Redis Sessions
How Spring Security Uses Sessions
For form login and traditional web applications, Spring Security stores the Authentication object in the HTTP session. On every request, SecurityContextPersistenceFilter (Spring Security 5) or SecurityContextHolderFilter (Spring Security 6) loads the SecurityContext from the session and puts it in the SecurityContextHolder.
For stateless APIs using JWT or OAuth2 Bearer tokens, no session is created — the token is verified on every request.
Session Creation Policy
Control when Spring Security creates sessions:
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // default
);
| Policy | Behavior |
|---|---|
IF_REQUIRED | Create a session when needed (default) |
ALWAYS | Always create a session |
NEVER | Never create a session, but use one if it already exists |
STATELESS | Never create or use a session — JWT/token APIs use this |
For REST APIs with JWT:
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable()); // CSRF not needed for stateless APIs
Session Fixation Protection
Session fixation is an attack where an attacker pre-establishes a session ID, tricks the user into authenticating with it, and then uses the known session ID to access the authenticated session.
Spring Security migrates the session ID on login by default:
http.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.migrateSession()) // default
);
| Strategy | Behavior |
|---|---|
migrateSession() | Create a new session, copy attributes from old session (default) |
newSession() | Create a new session, copy only Spring Security attributes |
changeSessionId() | Change the session ID without creating a new session (Servlet 3.1+) |
none() | No protection — do not use |
changeSessionId() is slightly more efficient (no session copy) and equally secure for most applications:
http.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId())
);
Concurrent Session Control
Limit how many simultaneous sessions a single user can have:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // one session per user
.maxSessionsPreventsLogin(false) // expire old session (true = reject new login)
.expiredUrl("/login?expired=true")
);
return http.build();
}
}
HttpSessionEventPublisher must be declared as a bean so Spring Security’s SessionRegistry is notified when sessions are created and destroyed.
Two behaviours when the limit is reached:
maxSessionsPreventsLogin(false)— the oldest session is invalidated; the new login succeedsmaxSessionsPreventsLogin(true)— the new login is rejected; the existing session continues
Querying Active Sessions
@Service
public class SessionManagementService {
private final SessionRegistry sessionRegistry;
public List<String> getActiveUsers() {
return sessionRegistry.getAllPrincipals().stream()
.filter(principal -> !sessionRegistry.getAllSessions(principal, false).isEmpty())
.map(principal -> ((UserDetails) principal).getUsername())
.collect(Collectors.toList());
}
public void forceLogout(String username) {
sessionRegistry.getAllPrincipals().stream()
.filter(p -> ((UserDetails) p).getUsername().equals(username))
.flatMap(p -> sessionRegistry.getAllSessions(p, false).stream())
.forEach(SessionInformation::expireNow);
}
}
Session Timeout
Configure session timeout in application.properties:
server.servlet.session.timeout=30m
Or programmatically per request:
@PostMapping("/login-extended")
public ResponseEntity<Void> loginExtended(HttpSession session) {
session.setMaxInactiveInterval(8 * 60 * 60); // 8 hours in seconds
return ResponseEntity.ok().build();
}
When a session expires, Spring Security redirects to the login page (for form login) or returns 401 (for API). Configure the expired session URL:
http.sessionManagement(session -> session
.maximumSessions(1)
.expiredUrl("/session-expired")
);
SecurityContext Save and Load
In Spring Security 6, SecurityContextRepository controls where the context is stored:
// Default — HTTP session
http.securityContext(ctx -> ctx
.securityContextRepository(new HttpSessionSecurityContextRepository())
);
// Stateless — no storage
http.securityContext(ctx -> ctx
.securityContextRepository(new NullSecurityContextRepository())
);
// Request-scoped — stored in request attributes (useful for async)
http.securityContext(ctx -> ctx
.requireExplicitSave(true) // Spring Security 6 default — explicit save required
);
With requireExplicitSave(true) (the Spring Security 6 default), the SecurityContext is only saved if explicitly marked dirty. This avoids unnecessary session writes on every request.
Distributed Sessions with Spring Session and Redis
In a multi-instance deployment, each server has its own in-memory session store. Load balancer without sticky sessions = authentication lost on the next request served by a different instance.
Spring Session externalizes the session store to Redis:
Dependencies
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
Configuration
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class RedisSessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName("redis-host");
config.setPort(6379);
return new LettuceConnectionFactory(config);
}
}
spring.data.redis.host=redis-host
spring.data.redis.port=6379
spring.session.store-type=redis
spring.session.timeout=30m
That’s all. Spring Session transparently intercepts HttpSession operations and redirects them to Redis. Spring Security needs no changes.
Redis Session with Sentinel (High Availability)
@Bean
public LettuceConnectionFactory connectionFactory() {
RedisSentinelConfiguration config = new RedisSentinelConfiguration()
.master("mymaster")
.sentinel("sentinel1", 26379)
.sentinel("sentinel2", 26379)
.sentinel("sentinel3", 26379);
return new LettuceConnectionFactory(config);
}
Remember-Me Sessions
Remember-me authentication lets users stay logged in across browser restarts via a persistent cookie.
Simple Hash-Based Remember-Me
http.rememberMe(remember -> remember
.key("unique-and-secret-key-stored-in-config")
.tokenValiditySeconds(14 * 24 * 60 * 60) // 14 days
.userDetailsService(userDetailsService)
);
The cookie contains: base64(username + ":" + expirationTime + ":" + md5(username + ":" + expirationTime + ":" + password + ":" + key))
If the user’s password changes, all existing remember-me tokens are invalidated automatically (the MD5 includes the password hash).
Persistent Token Remember-Me
Hash-based tokens have a flaw: the cookie contains enough to derive the hash. Use database-backed persistent tokens instead:
@Bean
public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
return repo;
}
// In SecurityFilterChain:
http.rememberMe(remember -> remember
.tokenRepository(persistentTokenRepository(dataSource))
.tokenValiditySeconds(14 * 24 * 60 * 60)
.userDetailsService(userDetailsService)
);
Required schema:
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
);
Each use rotates the token value (series stays constant). If a compromised token is used, the legitimate user’s next use will detect the mismatch and invalidate the entire series.
Session Security Headers
Set session cookie attributes to protect against XSS and network eavesdropping:
server.servlet.session.cookie.http-only=true # not accessible via JavaScript
server.servlet.session.cookie.secure=true # HTTPS only
server.servlet.session.cookie.same-site=strict # no cross-site sends
server.servlet.session.cookie.name=SESSIONID # avoid exposing server fingerprint
Or programmatically:
@Bean
public ServletContextInitializer sessionCookieConfig() {
return servletContext -> {
SessionCookieConfig config = servletContext.getSessionCookieConfig();
config.setHttpOnly(true);
config.setSecure(true);
config.setName("SESSION");
};
}
Complete Session Management Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.changeSessionId())
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/login?expired=true")
.and()
.invalidSessionUrl("/login?invalid=true")
)
.rememberMe(remember -> remember
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(7 * 24 * 60 * 60)
.userDetailsService(userDetailsService)
);
return http.build();
}
}
Key Takeaways
- Use
STATELESSsession policy for JWT/token APIs;IF_REQUIREDfor form login applications - Session fixation protection is on by default —
changeSessionId()is the most efficient strategy maximumSessions(1)withHttpSessionEventPublisherlimits concurrent logins per user- Spring Session + Redis externalizes sessions to a shared store — required for multi-instance deployments
- Persistent remember-me tokens rotate on each use and invalidate entire series on theft detection
- Set
HttpOnly,Secure, andSameSite=Stricton session cookies
Next: CSRF Protection: How It Works and When to Disable It — what CSRF attacks are, how the Synchronizer Token Pattern stops them, and when it is safe to disable.