Why does default concurrent session control break in a clustered/multi-instance deployment, and how do you fix it?
answer
- SessionRegistryImpl = per-JVM heap map
- Two nodes each see one session -> cap violated
- Sticky sessions don't cover second login/failover
- Spring Session + SpringSessionBackedSessionRegistry
- Shared store => drop HttpSessionEventPublisher
basics
~20 sThe default SessionRegistryImpl is an in-memory map local to one JVM, so each node counts only its own sessions. Across nodes the limit isn't truly enforced. Fix it with a shared session store like Spring Session (Redis/JDBC) and SpringSessionBackedSessionRegistry.
solid answer
~40 s`SessionRegistryImpl` holds the principal-to-sessions map in the heap of a single instance. In a horizontally scaled deployment behind a load balancer, a user's two sessions can land on two different nodes; each node's registry sees only one, so `maximumSessions(1)` is silently violated. Sticky sessions don't fully solve it because failover and rebalancing move users. The correct fix is a shared, externalized session store: adopt Spring Session (Redis, Hazelcast, or JDBC) so all nodes share session state, and replace the registry with `SpringSessionBackedSessionRegistry`, which queries the shared store instead of local memory. That registry also derives expiry from the store's own lifecycle, so you no longer need `HttpSessionEventPublisher`. Alternatives include a custom distributed `SessionRegistry`. Enforcement is only ever as consistent as the registry is shared.
code
java · 25 lines// Spring Session (Redis) makes sessions shared across nodes; the
// registry then reflects the global view of a principal's sessions.
@Configuration
@EnableWebSecurity
@EnableRedisHttpSession
public class ClusteredSessionConfig {
@Bean
SpringSessionBackedSessionRegistry<? extends Session> sessionRegistry(
FindByIndexNameSessionRepository<? extends Session> sessionRepository) {
return new SpringSessionBackedSessionRegistry<>(sessionRepository);
}
@Bean
SecurityFilterChain chain(HttpSecurity http,
SpringSessionBackedSessionRegistry<? extends Session> registry)
throws Exception {
http.sessionManagement(s -> s
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.sessionRegistry(registry));
return http.build();
// No HttpSessionEventPublisher needed: the store owns lifecycle/expiry.
}
}go deeper
Know the default registry is in-memory and single-JVM.
Explain that two nodes each undercount, so the cap leaks.
Prescribe Spring Session + SpringSessionBackedSessionRegistry and note the publisher becomes unnecessary.
Discuss latency/availability trade-offs, eventual-consistency races, JWT non-applicability, and treating shared session storage as an architectural requirement.
**The root problem: local state.** The default `SessionRegistryImpl` is an in-memory `ConcurrentHashMap` living in one JVM's heap. Concurrent session control decisions (count, pick-oldest, expire) all read that local map. This is fine for a single instance. **Why clusters break it.** Scale horizontally to N instances behind a load balancer and a single user's requests may be routed to different nodes. Session A registers in node-1's registry; session B registers in node-2's. With `maximumSessions(1)`, node-1 sees one session and node-2 sees one session — neither sees two — so the user ends up with two live sessions and the policy is silently violated. `maxSessionsPreventsLogin(true)` is equally defeated: no single node ever perceives the cap being exceeded. **Why sticky sessions are not a real fix.** Load-balancer session affinity keeps a *given* session on one node, but a *second* login can still be balanced to a different node (it has no affinity cookie yet, or uses a different route). On node failure/drain, sessions rebalance and the local registries are lost entirely, resetting counts and losing all bookkeeping. **The correct fix: externalize session state.** 1. Adopt **Spring Session** (`spring-session-data-redis`, `spring-session-hazelcast`, or `spring-session-jdbc`). Now the `HttpSession` itself lives in a shared store, replicated/visible to all nodes. 2. Replace the registry with `SpringSessionBackedSessionRegistry` (from `spring-session-core`), wired into `sessionManagement().maximumSessions(n).sessionRegistry(springSessionBackedRegistry)`. It implements `FindByIndexNameSessionRepository`-backed lookups so every node sees the *global* set of a principal's sessions. 3. Because the shared store manages session lifecycle and expiry natively, you typically **drop `HttpSessionEventPublisher` and `SessionRegistryImpl`** — the store is the source of truth. ```java @Bean SpringSessionBackedSessionRegistry<? extends Session> sessionRegistry( FindByIndexNameSessionRepository<? extends Session> repo) { return new SpringSessionBackedSessionRegistry<>(repo); } ``` **Trade-offs and caveats.** - Every login now does remote lookups against Redis/DB — added latency and a new failure domain. The store's availability becomes part of your auth-path SLA. - Expiry semantics shift to the store's TTL; the `expireNow()`/lazy-`ConcurrentSessionFilter` flow still applies but reads shared state. - Consistency is eventual in some stores; a race between two simultaneous logins on two nodes can momentarily exceed the cap. - A hand-rolled distributed `SessionRegistry` is possible but you're re-implementing what Spring Session already solved; rarely worth it. - Stateless JWT auth has *no* server session, so concurrent-session control via `SessionRegistry` doesn't apply at all — you'd enforce device limits with your own token/allow-list store instead. **Design guidance.** If a product requires 'one active session per user' at scale, the session-storage architecture (shared store) is a first-class requirement, not an afterthought. Choose Spring Session early; retrofitting concurrency control onto a sticky-session cluster is a common production trap.
- With SpringSessionBackedSessionRegistry, do you still register HttpSessionEventPublisher?No. The Spring Session store owns session lifecycle and expiry, so the registry reads it directly; the servlet-listener bridge that SessionRegistryImpl needed is no longer required.
- Does concurrent session control apply to a stateless JWT-based API?No — there is no server-side HttpSession to register, so SessionRegistry-based control is moot. You'd enforce device/session limits with your own token allow-list or refresh-token store instead.
saying these in an interview costs you the question
- Claiming sticky sessions fully solve cluster enforcement
- Assuming SessionRegistryImpl is shared across instances
- Expecting concurrent-session control to work with stateless JWT auth