Here’s the English translation of the article body:
You scale your Spring Boot app out to two instances behind a load balancer, and suddenly every reload sends you back to the login screen. It’s the classic problem you hit first when scaling out.
The cause isn’t your application code — it’s where sessions are stored. In this article, we’ll externalize sessions with Spring Session and Redis, building a setup where login state persists across multiple instances. We’ll go all the way through: adding dependencies, configuring spring.session.*, integrating with Spring Security, inspecting the data with redis-cli, and verifying the behavior with two instances.
Note that this article doesn’t cover when to choose token-based approaches like JWT instead. If you’re interested in token-based authentication, see How to Implement JWT Refresh Tokens with Spring Security.
Why Logins Disappear When You Scale Out
In a default Spring Boot setup (Tomcat), the actual HttpSession is stored in each instance’s JVM memory. The JSESSIONID cookie handed to the browser is just a claim ticket — the real thing lives on the server.
Now put two instances behind a load balancer: your login is recorded in instance A’s memory, but the next request gets routed to instance B. B has no session matching that ID, so you’re treated as unauthenticated. That’s exactly what’s happening when “reloading logs me out.”
You could pin users to the same instance with load balancer sticky sessions, but sessions still vanish on restarts and deploys, and load becomes uneven — it’s a stopgap at best. The proper solution is to put sessions in an external store visible to all instances. That’s where Spring Session + Redis comes in.
How Spring Session Works — Transparently Moving HttpSession to Redis
When you add Spring Session, a servlet filter called SessionRepositoryFilter wraps HttpSession, reading from and writing to Redis behind getAttribute / setAttribute.
The key point is that your application code can keep using the standard HttpSession API. No changes are needed to your controllers or to Spring Security’s implementation — the session data corresponding to the cookie returned to the browser (named SESSION by default) simply gets stored in Redis. There’s just one prerequisite: session attributes are serialized with JDK serialization by default, so any custom class you put into the session must implement Serializable.
Incidentally, this is a feature independent of @Cacheable-based caching and Pub/Sub. If you want an overview of Redis use cases in general, see the Spring Boot and Redis Integration Guide.
Adding Dependencies and Starting Redis
You only need two dependencies. With Maven, add the following:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
With Gradle:
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
Versions are managed by the Spring Boot BOM, so you don’t need to specify them. With just these two on the classpath, auto-configuration kicks in, SessionRepositoryFilter gets registered, and session storage switches over to Redis. That’s the entire setup.
For testing, start Redis with a one-liner in Docker:
docker run -d --name session-redis -p 6379:6379 redis:7
Redis high availability and production setups are outside the scope of this article. Containerization is covered in How to Containerize a Spring Boot App with Docker.
Configuring spring.session.* — timeout, namespace, and flush-mode
Let’s put the key properties, along with the connection settings, into application.yml:
spring:
data:
redis:
host: localhost
port: 6379
session:
timeout: 30m
redis:
namespace: 'myapp:session'
flush-mode: on-save
Here’s what each one means:
spring.session.timeoutis the session expiration time. Importantly, once Spring Session is in place, this takes precedence overserver.servlet.session.timeout. This trips people up when their existing setting suddenly seems to stop working, so watch out.spring.session.redis.namespaceis the prefix for Redis keys, defaulting tospring:session. If multiple apps share the same Redis instance, set a different value per app to prevent key collisions.spring.session.redis.flush-modedefaults toon-save, which writes everything in one batch when the response completes.immediatewrites to Redis every time an attribute is set, which means more Redis traffic, so sticking withon-saveis usually fine.
The property configuration system itself is covered in the Spring Boot Properties Configuration Guide.
Designing Cookies with CookieSerializer
For production, you’ll want to decide the cookie attributes yourself. You can customize them by defining a DefaultCookieSerializer Bean:
@Bean
public CookieSerializer cookieSerializer(
@Value("${app.cookie-secure:true}") boolean secure) {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSION");
serializer.setCookiePath("/");
serializer.setUseHttpOnlyCookie(true);
serializer.setUseSecureCookie(secure);
serializer.setSameSite("Lax");
return serializer;
}
The Secure attribute is a must for HTTPS production environments, but Secure cookies aren’t sent over HTTP, so local HTTP development would lose its login state. The practical approach is to toggle it via a property, as in the example above, and set app.cookie-secure=false locally. We’ll use this setting in the verification steps later.
Make Lax your default for SameSite. Consider None only when cross-site integration truly requires it — and beware the pitfall that None mandates the Secure attribute. Also, since the cookie name changes from the default JSESSIONID to SESSION, don’t forget to update any reverse proxies or monitoring that depend on the cookie name.
Integrating with Spring Security Form Login
Now let’s verify things with an actual login flow. Here’s a minimal Security configuration:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user").password("password").roles("USER").build();
return new InMemoryUserDetailsManager(user);
}
}
There are two testing-only shortcuts here. First, csrf(csrf -> csrf.disable()) is a test-only setting so we can send a login POST from curl without a CSRF token later on. If left enabled, a POST without a token returns 403. Do not disable CSRF protection in a production app serving browsers. Second, withDefaultPasswordEncoder() is a deprecated, demo-only API. For real user management, see How to Implement Database Authentication with UserDetailsService. The Security configuration itself is also explained in How to Implement Basic Authentication with Spring Security.
It also helps to create an endpoint that returns the logged-in username, which will make verification easier later:
@RestController
public class MeController {
@GetMapping("/me")
public String me(Principal principal) {
return principal.getName();
}
}
No special configuration is needed for the integration. On successful login, Spring Security stores the authentication information in a session attribute called SPRING_SECURITY_CONTEXT. Since Spring Session has swapped out HttpSession, this attribute gets written to Redis along with everything else, and the authentication state is automatically externalized.
Debugging Session Contents with redis-cli
You can see for yourself whether the data is “really in Redis.” While logged in, open redis-cli:
docker exec -it session-redis redis-cli
# セッションキーの一覧(KEYSは検証環境限定。本番はSCANを使う)
KEYS myapp:session:*
# 1) "myapp:session:sessions:4f1c..."
# 有効期限の残り秒数。spring.session.timeoutが反映されているか確認
TTL myapp:session:sessions:4f1c...
# セッション属性の確認
HGETALL myapp:session:sessions:4f1c...
# キーを消すと該当ユーザーを強制ログアウトできる
DEL myapp:session:sessions:4f1c...
In the default configuration, each session gets a single hash named {namespace}:sessions:{session ID}, with expiration managed via TTL. Running HGETALL should show creationTime, maxInactiveInterval, and — importantly — sessionAttr:SPRING_SECURITY_CONTEXT. The value is Java-serialized binary, so it’s normal that you can’t read it, but the very presence of this attribute is proof that the authentication state made it into Redis.
The forced logout via DEL is worth remembering for operational scenarios — say, when you need to immediately kill the session of a user suspected of unauthorized access.
Verifying Session Sharing Across Two Instances
Now for the main event. We’ll start the same app as two processes on different ports and check whether a cookie obtained by logging in on one works on the other. Since local traffic is HTTP, the key is to start with --app.cookie-secure=false to turn off the Secure attribute.
# 別ターミナルでそれぞれ起動(ローカルHTTP検証のためSecureをオフ)
./mvnw spring-boot:run \
-Dspring-boot.run.arguments="--server.port=8080 --app.cookie-secure=false"
./mvnw spring-boot:run \
-Dspring-boot.run.arguments="--server.port=8081 --app.cookie-secure=false"
# 8080でログインし、Cookieをファイルに保存
curl -v -c cookies.txt -d 'username=user&password=password' \
http://localhost:8080/login
# 同じCookieで8081の/meにアクセス
curl -b cookies.txt http://localhost:8081/me
You can tell whether the login succeeded from the -v output. On success, you get a 302 with the Location header pointing to the top page (http://localhost:8080/). On failure, Location becomes /login?error, so it’s easy to spot. Also run cat cookies.txt to confirm the SESSION cookie was saved.
If the final curl then returns user, you’ve succeeded. Instance 8081 is reading the session created on 8080 from Redis — meaning the “reloading logs me out” problem from the beginning of this article is solved.
Here’s an example configuration for starting everything with Docker Compose as well:
services:
redis:
image: redis:7
app1:
build: .
ports:
- '8080:8080'
environment:
SPRING_DATA_REDIS_HOST: redis
depends_on:
- redis
app2:
build: .
ports:
- '8081:8080'
environment:
SPRING_DATA_REDIS_HOST: redis
depends_on:
- redis
If things don’t work, check these three points: whether both instances point at the same Redis, whether the namespace values match, and whether the cookie path and Secure attribute suit your local environment.
How Redis Behaves on Timeout and Logout
Let’s also look at how sessions end.
On timeout, the Redis key expires the moment its TTL runs out. The next request finds no session and the user is redirected to the login page as unauthenticated. On explicit logout, Spring Security’s logout handling invalidates the session and the Redis key is deleted on the spot. If you run KEYS right after logging out, you should see the key is gone.
Choose your timeout value as a trade-off between security requirements and convenience. A shorter timeout is safer but means more re-logins, so around 30 minutes works well for business systems; for services that need long-lived logins, consider combining Remember-Me or a refresh token approach.
Summary
By adding just two dependencies and configuring spring.session.*, HttpSession is externalized to Redis and login state now persists across multiple instances.
spring.session.timeouttakes precedence overserver.servlet.session.timeout- If multiple apps share Redis, use separate
namespacevalues - Design your cookies with
CookieSerializer, covering HttpOnly, Secure, and SameSite - When in doubt, run
KEYSandTTLin redis-cli to see what’s going on
For Redis use cases beyond sessions, see the Spring Boot and Redis Integration Guide, and for production-ready container setups, the Docker containerization article.
One note: the comments inside the bash code blocks (the redis-cli session and the two-instance verification commands) were left in Japanese per the “keep all code examples unchanged” rule. If you’d like those inline comments translated too, let me know and I’ll provide a version with English comments.