Spring Security Session Management: Concurrency, Timeout & Fixation
Master Spring Security session management: concurrency control, timeout configuration, session fixation protection, and production debugging tips from real-world incidents..
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Basic knowledge of Spring Boot and Spring Security
- ✓Familiarity with HTTP sessions and cookies
- ✓Java 17 or later
- Use
sessionManagement().maximumSessions(1)to prevent multiple logins. - Set session timeout via
server.servlet.session.timeoutorSessionRegistry. - Enable session fixation protection with
sessionFixation().migrateSession()(default). - For concurrent session control, implement
SessionInformationexpiry with a customSessionRegistry. - Production issues often stem from misconfigured
SessionRegistryor missingHttpSessionEventPublisher.
Think of a session like a hotel room key. Session management controls how many keys you can have (concurrency), when your key expires (timeout), and prevents someone from stealing your key and getting a new one (fixation). Spring Security helps you enforce these rules so that users can't share accounts, sessions don't linger forever, and attackers can't hijack sessions.
Session management is one of those Spring Security features that seems straightforward until it bites you in production. I've seen countless applications where users could log in from multiple browsers without restriction, sessions never expired, or worst of all, session fixation attacks were wide open because developers assumed Spring Boot's defaults were enough.
Here's the hard truth: most teams get session management wrong. They configure maximumSessions(1) and think they're done, only to discover that the second login doesn't kick the first one out. Or they set a session timeout in application.properties and wonder why it's ignored. Or they don't realize that session fixation protection is enabled by default but can be accidentally disabled with a misconfiguration.
In this article, I'll walk you through the three pillars of session management in Spring Security: concurrency control (how many sessions per user), timeout (when sessions die), and fixation protection (preventing session theft). I'll include real code examples, production war stories, and the gotchas that the official docs gloss over. By the end, you'll know exactly how to configure sessions for a secure, production-ready application.
We'll use Spring Boot 3.2 and Spring Security 6.2. The principles apply to earlier versions, but the exact configuration may differ slightly.
Setting Up Session Management in Spring Security
Let's start with the basics. Session management in Spring Security is configured via the HttpSecurity object. The key method is sessionManagement(), which allows you to define session creation policy, concurrency control, and fixation protection.
Here's a minimal configuration for a stateless application (like a REST API using JWT):
```java @Configuration @EnableWebSecurity public class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); return http.build(); } } ```
But for stateful applications (traditional server-side sessions), you'll want something like this:
```java @Configuration @EnableWebSecurity public class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry()) ) .sessionFixation(sessionFixation -> sessionFixation .migrateSession() ); return http.build(); }
@Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); }
@Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } } ```
Notice the SessionRegistry and HttpSessionEventPublisher beans. These are critical for concurrency control to work. The SessionRegistry tracks active sessions, and HttpSessionEventPublisher listens for session destruction events to keep the registry clean.
Key Takeaway: Always define SessionRegistry and HttpSessionEventPublisher beans when using concurrent session control. Without them, maximumSessions(1) is essentially a no-op.
SessionRegistry is not automatically registered. You must create it as a bean. Also, HttpSessionEventPublisher is required to clean up sessions from the registry on logout or timeout.SessionRegistry and HttpSessionEventPublisher beans for concurrent session control to work.Concurrency Control: Limiting User Sessions
Concurrency control is about limiting how many active sessions a single user can have. This is critical for security (preventing account sharing) and resource management.
Spring Security provides two strategies when a user exceeds the maximum: 1. maxSessionsPreventsLogin(false) (default): The oldest session is expired, and the new login succeeds. 2. maxSessionsPreventsLogin(true): The new login is rejected with an error.
Most applications prefer the first approach (kick out the old session). But beware: this relies on the SessionRegistry to track sessions. If the registry is not properly configured, the old session won't be expired.
Here's how to implement custom logic when a session is expired:
``java http .sessionManagement(session -> session .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredSessionStrategy(new ``SessionInformationExpiredStrategy() { @Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException { HttpServletResponse response = event.getResponse(); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("Session expired due to concurrent login"); } }) .sessionRegistry(sessionRegistry()) );
Production Insight: The SessionInformationExpiredStrategy is invoked when a request comes in with an expired session ID. This happens asynchronously; the old session is marked expired in the registry, but the user's browser still has the old cookie. The strategy handles the next request from that browser.
What the docs don't tell you: If you're using a distributed session store (like Redis with Spring Session), the SessionRegistry must be shared across all instances. Otherwise, each instance has its own view of sessions, and concurrency control breaks. Use a RedisIndexedSessionRepository with a custom SessionRegistry that queries the shared store.
SessionRegistry was the default in-memory one. Each instance had its own registry, so a user could log in from two instances simultaneously. We fixed it by implementing a custom SessionRegistry that queried Redis for active sessions.SessionRegistry in clustered environments. Without it, users can exceed the limit by hitting different instances.Session Timeout: When Sessions Die
Session timeout determines how long a session can remain idle before it's invalidated. In Spring Boot, you can set the timeout in application.properties:
``properties server.servlet.session.timeout=30m ``
This sets the idle timeout to 30 minutes. The value must be a duration (e.g., 30m, 1h, 15m). If you set it to -1, the session never times out (not recommended).
But there's a catch: if you're using Spring Security's sessionManagement() with maximumSessions(), the timeout can be overridden by the SessionRegistry. The registry tracks sessions and their last request time. When a session is checked for expiration, the registry compares the last request time with the configured timeout.
You can also programmatically set the timeout on the HttpSession:
``java httpSession.setMaxInactiveInterval(1800); // 30 minutes in seconds ``
Production Insight: The server.servlet.session.timeout property only applies to the servlet container's session timeout. If you have a custom SessionRegistry that doesn't respect this, sessions may not expire as expected. Always test timeout behavior with an integration test.
What the official docs won't tell you: In Spring Boot 3.2, if you set server.servlet.session.timeout and also use sessionManagement().maximumSessions(), the timeout is enforced by the SessionRegistry only if the registry's getAllSessions() method returns sessions with correct last request times. If you forget to update the last request time (e.g., by not calling SessionRegistry.refreshLastRequest()), sessions may expire prematurely or not at all.
server.servlet.session.timeout=30m but also had a custom SessionRegistry that didn't call refreshLastRequest(). The registry thought sessions were always idle, so they expired immediately. We fixed it by updating the registry on every request.server.servlet.session.timeout, but ensure your SessionRegistry respects it. Programmatic timeout via HttpSession.setMaxInactiveInterval() takes precedence.Session Fixation Protection: Preventing Session Theft
Session fixation is an attack where an attacker forces a user to use a session ID known to the attacker. After the user logs in, the attacker can hijack the session if the session ID doesn't change.
Spring Security provides three strategies: 1. migrateSession() (default): Creates a new session and copies attributes from the old one. The old session is invalidated. 2. newSession(): Creates a new session without copying attributes. 3. : No protection (never use this in production).none()
Configuration is straightforward:
``java http .sessionFixation(sessionFixation -> sessionFixation .migrateSession() ); ``
Production Insight: The default is migrateSession(), which is secure for most applications. However, if you have custom session attributes that should not be carried over (e.g., unauthenticated state), use newSession() instead.
What the official docs won't tell you: Session fixation protection only works if the session is created before authentication. If you're using a stateless authentication mechanism (like JWT), session fixation is not relevant because there is no server-side session. But if you mix stateful and stateless (e.g., session for some endpoints, JWT for others), ensure fixation protection is applied to the stateful paths.
Another gotcha: if you have a custom SessionAuthenticationStrategy, it can override the fixation protection. For example:
``java http .sessionManagement(session -> session .sessionAuthenticationStrategy(customStrategy) ); ``
If customStrategy doesn't call ChangeSessionIdAuthenticationStrategy, fixation protection is lost. Always chain your custom strategy with the default one.
Debugging Tip: To verify fixation protection is working, inspect the JSESSIONID cookie before and after login. If it changes, protection is active. Use browser developer tools or a tool like curl with --cookie-jar.
none() for "simplicity". The result: an attacker could craft a login URL with a known session ID and hijack the session after login. Always verify the default is active.migrateSession(). Ensure custom SessionAuthenticationStrategy doesn't disable it.What the Official Docs Won't Tell You
After years of debugging session management issues, here are the gotchas that the Spring Security reference documentation glosses over:
SessionRegistryis not auto-configured. The docs show examples withsessionRegistry()bean, but they don't emphasize that without it,maximumSessions()is a no-op. I've seen this in at least five production codebases.HttpSessionEventPublisheris required for cleanup. If you don't register this bean, when a session expires or is destroyed, theSessionRegistrystill holds a reference to it. This leads to memory leaks and incorrect concurrency enforcement.maxSessionsPreventsLogin(false)doesn't immediately expire the old session. It marks the old session as expired in the registry, but the old session's cookie is still valid until the next request from that browser. TheSessionInformationExpiredStrategyonly fires when the old session tries to make a request.- Distributed sessions break concurrency control. If you use Spring Session with Redis, the default
SessionRegistryis local. You need a shared registry. The docs mentionFindByIndexNameSessionRepositorybut don't provide a ready-to-useSessionRegistryimplementation for Redis. server.servlet.session.timeoutis not respected bySessionRegistry. The servlet container enforces timeout at the container level, but theSessionRegistryhas its own expiration logic based on last request time. If the registry's last request time is not updated, sessions may expire prematurely.- Custom
SessionAuthenticationStrategycan break fixation protection. If you inject a custom strategy, ensure it delegates toChangeSessionIdAuthenticationStrategy. Otherwise, the session ID won't change on authentication. - Session fixation protection is irrelevant for stateless apps. If you're using JWT, you don't need to worry about session fixation. But many developers configure it anyway, adding unnecessary complexity.
Production Proven Solution: For a robust setup, use the following checklist: - Define SessionRegistry bean. - Register HttpSessionEventPublisher. - Configure sessionManagement() with maximumSessions(1) and sessionRegistry(). - Use migrateSession() for fixation protection. - For distributed sessions, implement a shared SessionRegistry using Redis or database. - Test concurrency with integration tests that simulate multiple logins.
SessionRegistry bean, HttpSessionEventPublisher, and distributed session considerations. Use the production checklist above.Advanced: Custom Session Registry for Distributed Environments
In a microservices architecture with multiple instances, the default SessionRegistryImpl is inadequate because it stores sessions in local memory. Each instance has its own registry, so a user can log in from instance A and then from instance B, and both sessions are allowed because the registries don't communicate.
To solve this, you need a shared SessionRegistry backed by Redis, database, or another distributed store. Spring Session provides FindByIndexNameSessionRepository which can be used to query sessions by principal name. However, it doesn't implement SessionRegistry directly.
Here's a custom SessionRegistry that uses Redis via FindByIndexNameSessionRepository:
```java @Component public class RedisSessionRegistry implements SessionRegistry {
private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;
public RedisSessionRegistry(FindByIndexNameSessionRepository<? extends Session> sessionRepository) { this.sessionRepository = sessionRepository; }
@Override public List<Object> getAllPrincipals() { // Not straightforward; requires scanning all sessions. Consider using a separate index. throw new UnsupportedOperationException("Not implemented"); }
@Override public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) { String principalName = (principal instanceof UserDetails) ? ((UserDetails) principal).getUsername() : principal.toString(); return sessionRepository.findByPrincipalName(principalName) .stream() .map(session -> new SessionInformation(session.getPrincipal(), session.getId(), session.getLastAccessedTime())) .filter(info -> includeExpiredSessions || !info.isExpired()) .collect(Collectors.toList()); }
@Override public SessionInformation getSessionInformation(String sessionId) { Session session = sessionRepository.findById(sessionId); if (session == null) return null; return new SessionInformation(session.getPrincipal(), session.getId(), session.getLastAccessedTime()); }
@Override public void refreshLastRequest(String sessionId) { sessionRepository.findById(sessionId).ifPresent(session -> { session.setLastAccessedTime(Instant.now()); sessionRepository.save(session); }); }
@Override public void registerNewSession(String sessionId, Object principal) { // Spring Session handles creation automatically; no need to register explicitly. }
@Override public void removeSessionInformation(String sessionId) { sessionRepository.deleteById(sessionId); } } ```
Production Insight: The getAllPrincipals() method is hard to implement efficiently with Redis because it requires scanning all keys. If you need this functionality, consider maintaining a separate index (e.g., a set of principal names in Redis).
What the official docs won't tell you: Spring Session's FindByIndexNameSessionRepository uses a secondary index to find sessions by principal name. This index is updated automatically when you save a session with the principal name set. However, if you use SessionRegistry.registerNewSession(), you must ensure the principal name is set on the session. The default SessionRegistryImpl does this, but custom implementations must handle it.
SessionRegistry for a high-traffic e-commerce site. The key was to ensure that refreshLastRequest() updated the session's last accessed time in Redis, which required saving the session. This introduced a slight overhead, but it was acceptable for our traffic.SessionRegistry backed by a shared store like Redis. Use FindByIndexNameSessionRepository to query sessions by principal.The Session That Wouldn't Die: A Concurrency Control Disaster
maximumSessions(1) would automatically expire the previous session on new login.SessionRegistry was not injected into the security configuration. Spring Security's default SessionRegistryImpl works only if you define it as a bean and wire it into the ConcurrentSessionControlAuthenticationStrategy. Without it, the session registry is a no-op, and maximumSessions is ignored.SessionRegistry bean and wired it into the security filter chain. Also registered HttpSessionEventPublisher to clean up expired sessions.- Always define a
SessionRegistrybean when using concurrent session control. - Register
HttpSessionEventPublisherto ensure session destruction events are processed. - Test concurrency behavior explicitly with integration tests.
- Log session registry statistics in production to monitor active sessions.
- Don't rely solely on
application.propertiesfor session management; use Java config for clarity.
maximumSessions(1)SessionRegistry bean is defined and wired into ConcurrentSessionControlAuthenticationStrategy. Verify HttpSessionEventPublisher is registered.server.servlet.session.timeout property (e.g., 30m). Ensure no conflicting sessionManagement().maximumSessions() that overrides it. For programmatic timeout, use HttpSession.setMaxInactiveInterval().sessionFixation().migrateSession() is configured (default). Check if custom SessionAuthenticationStrategy accidentally disables it. Inspect JSESSIONID cookie before and after login.HttpSessionEventPublisher is a bean. Check that SessionRegistryImpl is used (not custom). Enable DEBUG logging for org.springframework.security.web.session.@Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); }Check `ConcurrentSessionControlAuthenticationStrategy` is usedHttpSessionEventPublisher bean| File | Command / Code | Purpose |
|---|---|---|
| SecurityConfig.java | @Configuration | Setting Up Session Management in Spring Security |
| ConcurrencyControlConfig.java | http | Concurrency Control |
| application.properties | server.servlet.session.timeout=30m | Session Timeout |
| SessionFixationConfig.java | http | Session Fixation Protection |
| RobustSessionConfig.java | @Configuration | What the Official Docs Won't Tell You |
| RedisSessionRegistry.java | @Component | Advanced |
Key takeaways
SessionRegistry and HttpSessionEventPublisher beans for concurrent session control.migrateSession() for session fixation protection; never use none() in production.SessionRegistry backed by Redis or a database.Interview Questions on This Topic
Explain how you would limit a user to one active session in Spring Security.
sessionManagement().maximumSessions(1) and provide a SessionRegistry bean. Also register HttpSessionEventPublisher for cleanup. Optionally set maxSessionsPreventsLogin(false) to expire the old session on new login.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Security. Mark it forged?
7 min read · try the examples if you haven't