Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Session Management & Security

Sessions done safely: creation policies, session fixation defence, concurrent session limits, cookie flags that matter, and distributed sessions with Spring Session and Redis.

Intermediate6 min readUpdated
On this page

A session identifier is a bearer credential: whoever holds it is the user. Everything about session security follows from that — how it is created, how it is protected in transit and storage, and how quickly it can be revoked.

Key Takeaways

  • Regenerate the session id on login — this is the fixation defence and it is on by default.
  • Cookie flags — HttpOnly, Secure, SameSite — are the cheapest protection available.
  • Set both an idle and an absolute timeout.
  • Concurrent session limits turn a shared password into something visible.
  • Externalise sessions the moment you run more than one instance.

Creation policy

SessionConfig.java
http.sessionManagement(session -> session
    // ALWAYS       — create even when unused; wasteful
    // IF_REQUIRED  — the default, and correct for a web application
    // NEVER        — do not create, but use one that exists
    // STATELESS    — never create or use; correct for a token API
    .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
 
    // A new id on login. Without it, an attacker who plants a known id
    // holds a valid session once the victim authenticates.
    .sessionFixation(SessionFixationConfigurer::changeSessionId)
 
    .maximumSessions(3)
    .maxSessionsPreventsLogin(false)          // newest login wins
    .expiredUrl("/login?expired"));

STATELESS on a token-based API is worth setting explicitly. Without it, Spring may create a session per request that nothing ever reads — a slow memory leak that looks like a mystery until someone checks the session count.

Session fixation

The attack needs the session id to survive authentication. Regenerating it on login removes the premise entirely.

Spring enables this by default, which is one of its better defaults. The reason to know about it is that disabling it — sometimes done to work around a session-scoped bean problem — reintroduces a serious vulnerability.

Cookies

application.yml
server:
  servlet:
    session:
      timeout: 30m
      cookie:
        name: SESSION
        http-only: true      # JavaScript cannot read it — XSS cannot steal it
        secure: true         # HTTPS only
        same-site: lax       # not sent on cross-site POST — CSRF protection
        path: /
        max-age: -1          # session cookie: gone when the browser closes

HttpOnly is the single most valuable flag. It does not prevent XSS, but it removes the prize — an attacker with script execution can act as the user in that page, and cannot exfiltrate the session for use elsewhere.

SameSite=Lax blocks the cookie on cross-site POST, which is CSRF protection at the browser level. Keep CSRF tokens as well; two independent layers is right for something this cheap.

Lax rather than Strict is a deliberate choice, not a weaker default. Strict withholds the cookie even on a top-level navigation from another site, so a user following a link out of their email arrives at your application logged out — which reads as a bug and generates support tickets. Lax sends it on top-level GET navigations only, keeping inbound links working while still blocking the cross-site POST that CSRF relies on. None disables the protection altogether and requires Secure; set it only when a genuine cross-site embed needs the cookie.

max-age: -1 makes it a session cookie that disappears when the browser closes. A persistent cookie on a shared computer outlives the person who created it.

Idle and absolute timeouts

AbsoluteTimeoutFilter.java
@Component
public class AbsoluteSessionTimeoutFilter extends OncePerRequestFilter {
 
    private static final Duration MAX_LIFETIME = Duration.ofHours(8);
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
 
        HttpSession session = request.getSession(false);
        if (session != null) {
            Instant created = Instant.ofEpochMilli(session.getCreationTime());
            // The container's timeout is idle-based, so an actively used
            // stolen session never expires. This caps total lifetime.
            if (created.plus(MAX_LIFETIME).isBefore(Instant.now())) {
                session.invalidate();
                SecurityContextHolder.clearContext();
                response.sendRedirect("/login?expired");
                return;
            }
        }
        chain.doFilter(request, response);
    }
}

Both timeouts serve different threats. Idle protects an unattended machine; absolute bounds how long a compromised session remains useful.

Concurrent sessions

ConcurrentSessions.java
@Bean
SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}
 
@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
    // Required, and easy to forget. Without it the registry never learns
    // about destroyed sessions and the count only ever grows.
    return new HttpSessionEventPublisher();
}
SessionManagementController.java
@GetMapping("/account/sessions")
public List<SessionView> mySessions(@AuthenticationPrincipal UserDetails user) {
    return sessionRegistry.getAllSessions(user, false).stream()
            .map(info -> new SessionView(info.getSessionId(), info.getLastRequest(),
                                         metadata.deviceFor(info.getSessionId())))
            .toList();
}
 
@DeleteMapping("/account/sessions/{sessionId}")
public void revoke(@PathVariable String sessionId, @AuthenticationPrincipal UserDetails user) {
    sessionRegistry.getAllSessions(user, false).stream()
            .filter(info -> info.getSessionId().equals(sessionId))
            .findFirst()
            .ifPresent(SessionInformation::expireNow);
}

A visible session list with device and last-activity is one of the more effective security features you can ship. Users notice a session from a country they have never visited, and it costs an afternoon to build.

Distributed sessions

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>
application.yml
spring:
  session:
    store-type: redis
    timeout: 30m
    redis:
      namespace: 'acme:session'
      flush-mode: on_save
      # Required for concurrent session control and session listing across
      # instances — without it, each instance sees only its own.
      repository-type: indexed

Externalising sessions solves three problems at once: any instance can serve any request, a restart does not log everyone out, and sessions are visible and revocable across the whole fleet.

The trade is a dependency. A Redis outage now means nobody can authenticate, so it needs the same availability treatment as your database — replication, monitoring, and a tested failover.

One detail on the store itself is worth changing from the default. Spring Session serialises attributes with Java serialization, which means every session read deserialises objects out of Redis. Should an attacker ever gain write access to that instance, they have a remote code execution path rather than merely the ability to forge a session. Configure a JSON serializer instead, and treat the Redis instance as what it is — a store of live credentials for every signed-in user — with authentication enabled and no exposure beyond the private network.

Invalidating everywhere

RevokeAllSessions.java
@Transactional
public void changePassword(String userId, String newPassword) {
    User user = users.findById(userId).orElseThrow();
    user.setPasswordHash(encoder.encode(newPassword));
 
    // A password change must end every session, or an attacker who already
    // has one keeps their access despite the reset.
    sessionRepository.findByPrincipalName(user.username())
            .keySet()
            .forEach(sessionRepository::deleteById);
 
    trustedDevices.revokeAllFor(userId);
    notifications.send(user, "Your password was changed and all sessions ended");
}

This is the step most often missed in a password reset flow, and it defeats the entire purpose of the reset when it is. The same applies on MFA changes and on an explicit "sign out everywhere".

What to take away

Keep session fixation protection on and set all three cookie flags. Enforce an absolute lifetime alongside the idle timeout. Externalise sessions to Redis once you run more than one instance, give users a visible session list they can revoke, and end every session on a password change.

Frequently Asked Questions

Should an API be stateless?
For a machine or mobile client authenticating with a bearer token, yes — STATELESS avoids creating sessions nobody uses. For a browser application, a session cookie is often the better choice: HttpOnly protects it from XSS in a way a token in JavaScript memory cannot match, and revocation is immediate.
How long should a session last?
Idle timeout of 15-30 minutes for sensitive applications, a few hours for ordinary ones, plus an absolute maximum regardless of activity — typically 8-12 hours. The absolute cap matters because an idle timeout alone lets a stolen session live indefinitely as long as it is used.
Why does my session disappear behind a load balancer?
In-memory sessions live in one instance heap, so a request routed elsewhere finds nothing. Either enable sticky sessions, which breaks on instance loss, or externalise sessions with Spring Session backed by Redis — which is the answer that also survives a restart.

Related tutorials