Skip to main content
the auth layer

Session Fixation, Concurrent Sessions, and Logout Propagation

8 min read Chapter 21 of 45

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.

  1. Attacker visits https://app.saas.example/login. The server creates session S1 and sets the cookie.
  2. Attacker extracts the session ID S1 from the cookie.
  3. Attacker sends the victim a link: https://app.saas.example/login with the session cookie pre-set (via XSS on a subdomain, or via a meta tag redirect on a page the attacker controls).
  4. Victim clicks the link. The browser sends session S1.
  5. Victim enters credentials. Authentication succeeds.
  6. If the server does not change the session ID: session S1 is now authenticated.
  7. 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:

StrategyBehaviorTradeoff
changeSessionId()Changes the session ID, preserves attributesDefault. Correct for most cases.
migrateSession()Creates a new session, copies attributesSame as changeSessionId in Servlet 3.1+.
newSession()Creates a new session, discards attributesMost 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.