00:00

Session Management in Microservices

When you log in to a website, you expect it to remember you on every page until you log out. That memory is called a session. In a single application, sessions are easy: the server keeps your login in its own memory. In a microservices system, a request may reach any of dozens of service instances, so "remembering the user" needs a different design.

This tutorial explains what sessions are, why they are harder in microservices, and the main ways to manage them: sticky sessions, a shared session store, stateless JWT tokens and gateway-managed sessions. Each approach comes with simple Spring Boot examples.

1. What Is a Session?

HTTP is stateless: every request stands alone, and the server does not automatically know that two requests came from the same person. A session adds that memory. It links a series of requests to one user, so the system knows who you are, what you are allowed to do, and things like what is in your cart.

Simple analogy: At a theme park, you show your ticket once at the entrance and receive a wristband. After that, every ride checks your wristband instead of asking for your ticket again. A session ID or token is that wristband.

Session management covers the whole lifecycle: creating the session at login, identifying the user on each request, storing session data, expiring it after inactivity, and ending it at logout.

2. How Sessions Work in a Monolith

In a traditional single application, the server stores session data in its own memory and sends the browser a cookie with a random session ID (in Java, usually called JSESSIONID). The browser sends the cookie back on every request, and the server looks up the session.

Monolith session flow
1. Browser --- POST /login (username, password) ---> Server
2. Server  creates session {id: abc123, user: "asha"} in its memory
3. Server  --- Set-Cookie: JSESSIONID=abc123 ---> Browser
4. Browser --- GET /orders  (Cookie: JSESSIONID=abc123) ---> Server
5. Server  finds session abc123 in memory: this is "asha"

This works well because there is only one server, and it always holds the session.

3. Why Sessions Are Hard in Microservices

  • Many instances: each service runs several copies behind a load balancer. If your session lives in instance A's memory, a request routed to instance B does not know you.
  • Many services: the User, Order and Payment services all need to know who you are, but they are separate applications.
  • Instances come and go: auto-scaling and deployments start and stop instances constantly. Sessions stored in memory are lost when an instance stops.
  • Scaling needs statelessness: microservices scale best when any instance can serve any request.
  • Security across boundaries: user identity must be passed safely from service to service.
The problem
Request 1 (login)  --> Load Balancer --> Order Service A  (session stored here)
Request 2 (orders) --> Load Balancer --> Order Service B  ("Who are you?")

4. Session Management Approaches

There are four common solutions. Each one answers the question "where does the session live?" differently:

  1. Sticky sessions: the load balancer always sends a user to the same instance.
  2. Shared session store: sessions live in a central store such as Redis that every instance can read.
  3. Stateless tokens (JWT): the client carries a signed token that contains the user's identity; servers store nothing.
  4. Gateway-managed sessions: the API Gateway keeps a session with the browser and passes tokens to the services behind it.

5. Approach 1: Sticky Sessions

With sticky sessions (also called session affinity), the load balancer remembers which instance served a user, usually through a cookie, and sends all of that user's requests to the same instance. The session can stay in that instance's memory.

  • Pros: no code changes; easy way to move an existing application behind a load balancer.
  • Cons: if the instance crashes or is redeployed, its users are logged out; load can become uneven; scaling down is harder; and it only works within one service, not across services.

Sticky sessions are a short-term fix, not a good long-term design for microservices.

6. Approach 2: Shared Session Store (Spring Session + Redis)

Instead of keeping sessions in each instance's memory, all instances store them in a shared, fast store such as Redis. The browser still uses a session cookie, but any instance can look the session up. With Spring Session, this needs almost no code changes.

Shared session store
Browser (Cookie: SESSION=abc123)
    |
    v
[ Load Balancer ] ---> Instance A --+
                  ---> Instance B --+--> [ Redis ]
                  ---> Instance C --+     session abc123 = {user: "asha", cart: [...]}

Step 1: Add the Dependencies

pom.xml
<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>

Step 2: Configure Redis and Session Timeout

application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
  session:
    timeout: 30m               # log out after 30 minutes of inactivity
    redis:
      namespace: shop:session  # prefix for session keys in Redis

server:
  servlet:
    session:
      cookie:
        http-only: true        # JavaScript cannot read the cookie
        secure: true           # only sent over HTTPS
        same-site: lax         # basic protection against cross-site requests

Step 3: Use the Session as Usual

CartController.java
package com.javabytechie.shop.cart;

import jakarta.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/cart")
public class CartController {

    private static final String CART_KEY = "cart";

    @PostMapping("/items")
    public List<String> addItem(@RequestBody String productId, HttpSession session) {
        List<String> cart = getCart(session);
        cart.add(productId);
        session.setAttribute(CART_KEY, cart); // saved to Redis, visible to every instance
        return cart;
    }

    @GetMapping
    public List<String> viewCart(HttpSession session) {
        return getCart(session);
    }

    @SuppressWarnings("unchecked")
    private List<String> getCart(HttpSession session) {
        Object cart = session.getAttribute(CART_KEY);
        return cart != null ? (List<String>) cart : new ArrayList<>();
    }
}

The code uses the normal HttpSession API. Spring Session quietly stores it in Redis, so it survives restarts and works across instances.

  • Pros: easy to adopt; sessions can be revoked instantly by deleting them; works with existing session-based code.
  • Cons: every request needs a Redis lookup; Redis must be highly available, or nobody can log in; every service that reads the session needs access to Redis.

7. Approach 3: Stateless JWT Tokens

A JSON Web Token (JWT) is a compact, digitally signed token that contains information about the user, such as their ID and roles. After login, the authentication service gives the client a JWT. The client sends it in the Authorization header on every request, and each service checks the signature to trust it. No server needs to store the session. See What Is JWT for a deeper look.

JWT structure
header.payload.signature

Header:    {"alg": "HS256", "typ": "JWT"}
Payload:   {"sub": "asha", "roles": ["CUSTOMER"], "iat": 1767225600, "exp": 1767226500}
Signature: created with a secret or private key; any change to the token breaks it
JWT flow
1. Client --- POST /auth/login ---> Auth Service
2. Auth Service checks the password and returns a signed JWT (valid for 15 minutes)
3. Client --- GET /orders   (Authorization: Bearer <jwt>) ---> Order Service
4. Order Service verifies the signature and expiry, then reads the user from the token
   (no database or session lookup needed)

Issuing and Validating a JWT (Auth Service)

This example uses the open-source JJWT library (version 0.12 or later):

JwtService.java
package com.javabytechie.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class JwtService {

    private static final Duration ACCESS_TOKEN_LIFETIME = Duration.ofMinutes(15);

    private final SecretKey signingKey;

    // The secret must be at least 32 characters for HS256 and must come from configuration,
    // never from source code.
    public JwtService(@Value("${security.jwt.secret}") String secret) {
        this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String createAccessToken(String username, List<String> roles) {
        Instant now = Instant.now();
        return Jwts.builder()
                .id(UUID.randomUUID().toString())  // unique token ID (jti) for logout
                .subject(username)
                .claim("roles", roles)
                .issuer("javabytechie-auth")
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(ACCESS_TOKEN_LIFETIME)))
                .signWith(signingKey)
                .compact();
    }

    /** Returns the claims if valid; throws JwtException if forged or expired. */
    public Claims validate(String token) throws JwtException {
        return Jwts.parser()
                .verifyWith(signingKey)
                .requireIssuer("javabytechie-auth")
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

Making Other Services Stateless (Spring Security)

Downstream services do not need to create tokens; they only verify them. Spring Security's resource server support does this with a few lines:

SecurityConfig.java (order-service)
package com.javabytechie.orders.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // No server-side session: every request must carry its own token.
            .sessionManagement(session ->
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // CSRF protection is for cookie sessions; bearer tokens are not auto-sent.
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/actuator/health").permitAll()
                    .anyRequest().authenticated())
            // Validate the JWT in the Authorization header on every request.
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}
application.yml (order-service)
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Public keys are downloaded from the identity provider to verify token signatures.
          issuer-uri: https://auth.example.com/realms/shop

This setup expects tokens signed by an identity provider such as Keycloak or another OAuth 2.0 server, which publishes its public keys. Services then trust tokens without ever sharing a secret.

  • Pros: no session storage; any instance of any service can verify the token; scales very well.
  • Cons: a token stays valid until it expires, so logout and revocation need extra work; tokens grow if you add many claims; payloads are only encoded, not encrypted.

8. Refresh Tokens and Logout

Because a JWT cannot be "taken back", systems combine two tokens:

  • Access token: short-lived (for example 5–15 minutes), sent with every API request.
  • Refresh token: long-lived (hours or days), stored securely and used only to get a new access token. It is tracked on the server, so it can be revoked.

To log a user out immediately, add the access token's unique ID (jti) to a blocklist until the token would have expired anyway:

TokenBlocklist.java
package com.javabytechie.auth;

import java.time.Duration;
import java.time.Instant;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class TokenBlocklist {

    private static final String PREFIX = "blocked-token:";

    private final StringRedisTemplate redis;

    public TokenBlocklist(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /** Called on logout. The entry deletes itself when the token would have expired. */
    public void block(String tokenId, Instant tokenExpiresAt) {
        Duration remaining = Duration.between(Instant.now(), tokenExpiresAt);
        if (!remaining.isNegative()) {
            redis.opsForValue().set(PREFIX + tokenId, "logged-out", remaining);
        }
    }

    /** Checked on each request, after the signature is verified. */
    public boolean isBlocked(String tokenId) {
        return Boolean.TRUE.equals(redis.hasKey(PREFIX + tokenId));
    }
}

The blocklist only holds tokens that were logged out early, so it stays small. Short access-token lifetimes keep it smaller still.

9. Approach 4: Gateway-Managed Sessions

For browser applications, a popular design combines the best of both worlds. The browser keeps a simple, secure session cookie with the API Gateway (or a "Backend for Frontend"). The gateway stores the user's tokens on the server side and forwards an access token to the microservices on each request.

Gateway-managed session
 Browser
    |  HttpOnly session cookie
    v
 [ API Gateway ] ----> keeps tokens in its session store (e.g. Redis)
    |                  and refreshes access tokens automatically
    |  Authorization: Bearer <access token>
    v
 Microservices (Order, Payment, ...)
  • Pros: tokens never reach the browser, so JavaScript cannot steal them; services stay stateless; logout happens in one place.
  • Cons: the gateway becomes stateful and must be highly available; adds one more component to run.

Spring Cloud Gateway supports this with Spring Security's OAuth 2.0 login and its TokenRelay filter, which forwards the user's access token to downstream services.

10. Passing the User Between Services

When the Order Service calls the Payment Service on behalf of a user, the Payment Service also needs to know who the user is. The simplest approach is to forward the same access token:

PaymentClientConfig.java (order-service)
package com.javabytechie.orders.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.client.RestClient;

@Configuration
public class PaymentClientConfig {

    @Bean
    public RestClient paymentClient(RestClient.Builder builder) {
        return builder
                .baseUrl("http://payment-service")
                .requestInterceptor((request, body, execution) -> {
                    Authentication auth =
                            SecurityContextHolder.getContext().getAuthentication();
                    if (auth instanceof JwtAuthenticationToken jwtAuth) {
                        // Forward the caller's token so Payment Service sees the same user.
                        String token = jwtAuth.getToken().getTokenValue();
                        request.getHeaders().setBearerAuth(token);
                    }
                    return execution.execute(request, body);
                })
                .build();
    }
}

Never pass the user's identity as a plain header such as X-User-Id that any caller could fake, unless the network is locked down so only the gateway can set it.

11. Comparing the Approaches

AspectSticky SessionsShared Store (Redis)Stateless JWTGateway-Managed
Where state livesOne app instanceCentral storeInside the tokenGateway session store
Survives instance crashNoYesYesYes
ScalabilityLimitedGoodExcellentGood
Instant logoutYesYesNeeds blocklistYes
Works across servicesNoIf all share the storeYesYes
Extra infrastructureLoad balancer settingHighly available RedisIdentity providerGateway + session store
Best forQuick fix for legacy appsSession-heavy web appsAPIs, mobile apps, service-to-serviceBrowser apps (SPAs)

12. Example: An Online Store Login

Here is how a typical online store combines these ideas:

  1. Login: the customer signs in through the identity provider (for example Keycloak). The API Gateway receives an access token (valid 10 minutes) and a refresh token, stores them in Redis, and gives the browser an HttpOnly, Secure session cookie.
  2. Browsing: each request from the browser carries the cookie. The gateway finds the session, adds Authorization: Bearer <access token> and forwards the request.
  3. Placing an order: the Order Service validates the JWT, reads the customer ID from it, and forwards the same token when calling the Payment Service.
  4. Token refresh: when the access token expires, the gateway uses the refresh token to get a new one. The customer notices nothing.
  5. Logout: the gateway deletes the session from Redis and revokes the refresh token. Any copy of the old access token expires within minutes.

The result: services stay stateless and easy to scale, tokens never reach browser JavaScript, and logout works reliably.

13. Security Best Practices

  1. Always use HTTPS so session cookies and tokens cannot be read in transit.
  2. Mark session cookies HttpOnly, Secure and SameSite.
  3. Keep access tokens short-lived (minutes) and use refresh tokens for longer sessions.
  4. Rotate refresh tokens on every use and revoke them on logout or suspicious activity.
  5. Validate the signature, expiry, issuer and audience of every JWT.
  6. Never put passwords or sensitive personal data in a JWT; its payload is readable by anyone.
  7. Prefer asymmetric signing (RS256, ES256) so services only need the public key.
  8. Avoid storing tokens in browser localStorage, where injected scripts can read them.
  9. Use CSRF protection whenever authentication relies on cookies.
  10. Expire inactive sessions and give users a way to log out of all devices.

For the basics of identity and permissions, see Authentication and Authorization.

14. Common Mistakes

  • Keeping sessions in instance memory and wondering why users are randomly logged out after scaling or deployment.
  • Long-lived JWTs (days or weeks) with no way to revoke them.
  • Trusting a token without checking its signature, or accepting tokens with the none algorithm.
  • Storing large objects in the session, which makes every request slower.
  • Hard-coding signing secrets in source code instead of a secrets manager.
  • A single, non-replicated Redis for sessions, which becomes a single point of failure.

15. Conclusion

Session management in microservices means remembering users without tying them to a single server. Sticky sessions are a quick but fragile fix. A shared session store such as Redis with Spring Session keeps familiar session code and works across instances. Stateless JWTs let every service verify users on its own and scale best, combined with short lifetimes, refresh tokens and a blocklist for logout. For browser applications, a gateway-managed session keeps tokens safely on the server while services stay stateless. Choose the approach that fits your clients and security needs, protect tokens and cookies carefully, and your users will stay securely logged in, no matter which instance serves them.