Session Fixation, Concurrent Sessions, and Logout Propagation
Session Fixation, Concurrent Sessions, and Logout Propagation
The Assumption
Spring Security handles session fixation by default. The assumption: the default configuration is sufficient.
Spring Security’s default is changeSessionId(), which calls HttpServletRequest.changeSessionId() after authentication. This prevents the classic session fixation attack on a single server. In a distributed session store, the old session ID’s data still exists in Redis until TTL expiry unless explicitly deleted.
The second assumption: concurrent session limits work across instances. The default SessionRegistryImpl is in-memory. It tracks sessions for the local JVM only. In a clustered deployment, each instance maintains its own session registry. A user can have one session per instance, bypassing the intended limit.
The Attack
Session Fixation
The attacker’s goal: force the victim to authenticate with a session ID the attacker already knows.
- Attacker visits
https://app.saas.example/login. The server creates sessionS1and sets the cookie. - Attacker extracts the session ID
S1from the cookie. - Attacker sends the victim a link:
https://app.saas.example/loginwith the session cookie pre-set (via XSS on a subdomain, or via a meta tag redirect on a page the attacker controls). - Victim clicks the link. The browser sends session
S1. - Victim enters credentials. Authentication succeeds.
- If the server does not change the session ID: session
S1is now authenticated. - Attacker uses session
S1(which they know) to access the authenticated application.
The changeSessionId() defense: after step 5, the server changes the session ID from S1 to S2. The cookie is updated. The attacker’s S1 is no longer valid.
The distributed gap: with Spring Session and Redis, changeSessionId() creates a new Redis key for S2 and migrates the session attributes. But the old key S1 remains in Redis (with no authentication data, but with the session structure) until its TTL expires. This is a minor information leak, not a functional vulnerability, because the old session no longer contains the security context.
Credential Sharing via Unlimited Sessions
Without concurrent session limits, a single set of credentials can maintain unlimited active sessions. A SaaS user shares their login with 20 colleagues. The platform loses 19 subscriptions worth of revenue. From a security perspective: 20 sessions means 20 attack surfaces. Compromising any one of the 20 devices compromises the account.
The Spec or Mechanism
Spring Security’s SessionManagementConfigurer provides three session fixation strategies:
| Strategy | Behavior | Tradeoff |
|---|---|---|
changeSessionId() | Changes the session ID, preserves attributes | Default. Correct for most cases. |
migrateSession() | Creates a new session, copies attributes | Same as changeSessionId in Servlet 3.1+. |
newSession() | Creates a new session, discards attributes | Most secure. Loses shopping cart, form data, etc. |
For the SaaS platform, changeSessionId() is correct. Session attributes include tenant context and CSRF tokens, which must survive authentication.
Concurrent session control uses SessionRegistry to track active sessions per principal. The maximumSessions() setting limits how many concurrent sessions a user can have. Two enforcement modes:
- Expire oldest (default): New login succeeds. The oldest session is marked as expired. On the next request with the expired session, the user sees an error.
- Block new login: New login fails if the maximum is reached. The user must log out from an existing session first.
The Implementation
Session Fixation Prevention
@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId())
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(3)
.maxSessionsPreventsLogin(false) // Expire oldest, don't block
.sessionRegistry(sessionRegistry())
.expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\":\"session_expired\",\"message\":\"Your session was terminated because you logged in from another device\"}");
})
)
.build();
}
}
Distributed SessionRegistry with Spring Session
// VULNERABLE: In-memory SessionRegistry (does not work in clustered deployment)
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl(); // Only tracks sessions on this JVM
}
// HARDENED: Spring Session-backed SessionRegistry
@Bean
public SessionRegistry sessionRegistry(
FindByIndexNameSessionRepository<? extends Session> sessionRepository) {
return new SpringSessionBackedSessionRegistry<>(sessionRepository);
}
The SpringSessionBackedSessionRegistry queries Redis for sessions by principal name using the index maintained by RedisIndexedSessionRepository. When checking concurrent session limits, it finds all sessions across all instances, not just local sessions.
Logout Handler Chain
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.logout(logout -> logout
.logoutUrl("/api/logout")
.addLogoutHandler(securityContextLogoutHandler())
.addLogoutHandler(cookieClearingLogoutHandler())
.addLogoutHandler(tokenRevocationLogoutHandler())
.addLogoutHandler(oidcBackChannelLogoutHandler())
.logoutSuccessHandler((request, response, auth) -> {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json");
response.getWriter().write("{\"status\":\"logged_out\"}");
})
)
.build();
}
// Handler 1: Invalidate the session in Redis
@Bean
public SecurityContextLogoutHandler securityContextLogoutHandler() {
SecurityContextLogoutHandler handler = new SecurityContextLogoutHandler();
handler.setInvalidateHttpSession(true);
handler.setClearAuthentication(true);
return handler;
}
// Handler 2: Clear the session cookie
@Bean
public CookieClearingLogoutHandler cookieClearingLogoutHandler() {
return new CookieClearingLogoutHandler("__Host-SESSION");
}
// Handler 3: Revoke associated OAuth2 tokens
@Bean
public LogoutHandler tokenRevocationLogoutHandler() {
return (request, response, authentication) -> {
if (authentication instanceof OAuth2AuthenticationToken oauthToken) {
OAuth2AuthorizedClient client = authorizedClientService
.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken.getName());
if (client != null && client.getRefreshToken() != null) {
// Revoke the refresh token at the authorization server
restClient.post()
.uri("https://auth.saas.example/oauth2/revoke")
.body("token=" + client.getRefreshToken().getTokenValue()
+ "&token_type_hint=refresh_token")
.header("Authorization", basicAuth("frontend-shell", "secret"))
.retrieve()
.toBodilessEntity();
}
authorizedClientService.removeAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken.getName());
}
};
}
OIDC Back-Channel Logout
When the authorization server is an OIDC provider (Keycloak, your own Spring Authorization Server), back-channel logout notifies relying parties when a user’s session ends at the provider. The provider sends a POST request with a Logout Token (a signed JWT) to each relying party’s back-channel logout endpoint.
// Handler 4: Process incoming back-channel logout notifications
@Bean
public LogoutHandler oidcBackChannelLogoutHandler() {
return (request, response, authentication) -> {
// This handler is for outgoing logout propagation.
// Incoming back-channel logout is handled by a separate endpoint.
};
}
// Endpoint that receives back-channel logout notifications from the OIDC provider
@RestController
public class BackChannelLogoutController {
private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;
private final JwtDecoder logoutTokenDecoder;
@PostMapping("/logout/backchannel")
public ResponseEntity<Void> handleBackChannelLogout(
@RequestParam("logout_token") String logoutToken) {
// Validate the Logout Token (signed JWT with specific claims)
Jwt decoded = logoutTokenDecoder.decode(logoutToken);
// Extract the subject (user who logged out at the provider)
String subject = decoded.getSubject();
// Verify the event claim indicates a logout
Map<String, Object> events = decoded.getClaim("events");
if (events == null || !events.containsKey(
"http://schemas.openid.net/event/backchannel-logout")) {
return ResponseEntity.badRequest().build();
}
// Find and destroy all sessions for this user
Map<String, ? extends Session> sessions =
sessionRepository.findByPrincipalName(subject);
sessions.values().forEach(session -> {
sessionRepository.deleteById(session.getId());
});
return ResponseEntity.ok().build();
}
}
”Sign Out Everywhere”
The user clicks “Sign out from all devices” in account settings:
@PostMapping("/api/account/logout-everywhere")
public ResponseEntity<Void> logoutEverywhere(Authentication authentication) {
String username = authentication.getName();
// Find all sessions for this user across all instances
Map<String, ? extends Session> sessions =
sessionRepository.findByPrincipalName(username);
String currentSessionId = RequestContextHolder.currentRequestAttributes()
.getSessionId();
// Invalidate every session except the current one
sessions.forEach((sessionId, session) -> {
if (!sessionId.equals(currentSessionId)) {
sessionRepository.deleteById(sessionId);
}
});
// Revoke all OAuth2 tokens
revokeAllTokensForUser(username);
return ResponseEntity.ok().build();
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class SessionSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private FindByIndexNameSessionRepository<? extends Session> sessionRepository;
@Test
void sessionIdChangesAfterAuthentication() throws Exception {
// Get pre-authentication session
MvcResult preAuth = mockMvc.perform(get("/login"))
.andReturn();
String preAuthSessionId = preAuth.getRequest().getSession().getId();
// Authenticate
MvcResult postAuth = mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "correct-password")
.session((MockHttpSession) preAuth.getRequest().getSession()))
.andExpect(status().is3xxRedirection())
.andReturn();
String postAuthSessionId = postAuth.getRequest().getSession().getId();
// Session ID must change after authentication
assertThat(postAuthSessionId).isNotEqualTo(preAuthSessionId);
// Old session must not exist in Redis
assertThat(sessionRepository.findById(preAuthSessionId)).isNull();
}
@Test
void concurrentSessionLimitIsEnforced() throws Exception {
// Create 3 sessions (the maximum)
for (int i = 0; i < 3; i++) {
mockMvc.perform(post("/login")
.param("username", "bob")
.param("password", "correct-password"))
.andExpect(status().is3xxRedirection());
}
// 4th login should succeed but expire the oldest session
mockMvc.perform(post("/login")
.param("username", "bob")
.param("password", "correct-password"))
.andExpect(status().is3xxRedirection());
// Verify only 3 active sessions exist
Map<String, ? extends Session> sessions =
sessionRepository.findByPrincipalName("bob");
assertThat(sessions).hasSize(3);
}
@Test
void logoutInvalidatesSessionInRedis() throws Exception {
// Authenticate
MvcResult loginResult = mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "correct-password"))
.andReturn();
MockHttpSession session = (MockHttpSession) loginResult.getRequest().getSession();
String sessionId = session.getId();
// Verify session exists
assertThat(sessionRepository.findById(sessionId)).isNotNull();
// Logout
mockMvc.perform(post("/api/logout")
.session(session))
.andExpect(status().isOk());
// Session must be deleted from Redis
assertThat(sessionRepository.findById(sessionId)).isNull();
}
@Test
void logoutEverywhereInvalidatesAllOtherSessions() throws Exception {
// Create 3 sessions for the same user
List<String> sessionIds = new ArrayList<>();
MockHttpSession currentSession = null;
for (int i = 0; i < 3; i++) {
MvcResult result = mockMvc.perform(post("/login")
.param("username", "carol")
.param("password", "correct-password"))
.andReturn();
sessionIds.add(result.getRequest().getSession().getId());
if (i == 2) {
currentSession = (MockHttpSession) result.getRequest().getSession();
}
}
// Logout everywhere from the third session
mockMvc.perform(post("/api/account/logout-everywhere")
.session(currentSession))
.andExpect(status().isOk());
// Only the current session should survive
Map<String, ? extends Session> remaining =
sessionRepository.findByPrincipalName("carol");
assertThat(remaining).hasSize(1);
assertThat(remaining.containsKey(currentSession.getId())).isTrue();
}
}
The first test is the session fixation regression test: it proves that authentication changes the session ID and removes the old session from Redis. If someone misconfigures session fixation protection (sets it to none()), this test fails. The third test proves that logout is not just a cookie clear but an actual deletion from the shared session store, preventing session resurrection if the cookie value is captured before logout.