Skip to main content
the auth layer

Spring Session with Redis: Serialization, Key Structure, and TTL Management

6 min read Chapter 20 of 45

Spring Session with Redis

The Assumption

Spring Session with Redis is a drop-in replacement for the servlet container’s session management. Add the dependency, configure the connection, and sessions are distributed. The assumption: the default serialization and key structure are production-ready.

The default serializer is JDK serialization. JDK serialization in a session store is a remote code execution vector waiting for a gadget chain.

The Attack

Deserialization gadget chain via session poisoning. An attacker finds a way to inject a crafted serialized object into the session store. This can happen through:

  1. A vulnerable endpoint that writes attacker-controlled data to the session.
  2. Direct Redis access (compromised credentials, exposed port).
  3. A server-side request forgery that allows writing to Redis.

The crafted object uses a gadget chain (a sequence of classes on the classpath whose deserialization methods trigger arbitrary actions). Common gadget chains in Java applications include:

  • Commons Collections: InvokerTransformer chain, triggers Runtime.exec().
  • Spring Framework: MethodInvokeTypeProvider chain, triggers arbitrary method invocation.
  • Groovy: MethodClosure chain, triggers code execution.

When any application instance deserializes the session from Redis, the gadget chain executes. The attacker achieves remote code execution on every instance that loads that session.

This is not theoretical. CVE-2015-4852 (WebLogic), CVE-2015-7501 (JBoss), and CVE-2017-5638 (Apache Struts) all exploited Java deserialization. The same class of vulnerability exists in any application that deserializes untrusted data from a shared store.

The Spec or Mechanism

Spring Session stores each session as a Redis hash. The default key structure:

Spring Session Redis data structure showing the session hash fields, expiry keys, and the serialization attack surface in sessionAttr fields

The diagram breaks down the three Redis key types Spring Session creates for each session. The critical detail is the sessionAttr:* fields in the session hash: these contain serialized Java objects. With JDK serialization (the default), these fields accept arbitrary class instantiation during deserialization, which is the RCE attack vector exploited in CVE-2015-4852 and CVE-2015-7501. Switching to JSON serialization with a type allowlist eliminates this entire class of attack.

The sessionAttr:* fields contain the serialized session attributes. With JDK serialization, these are raw Java object streams. With JSON serialization, these are JSON strings with type information.

The serializer is configured via RedisSessionRepository.setDefaultSerializer() or by providing a custom RedisSerializer<Object> bean named springSessionDefaultRedisSerializer.

The Implementation

Vulnerable: JDK Serialization (Default)

// VULNERABLE: Default configuration uses JDK serialization
@Configuration
@EnableRedisIndexedHttpSession
public class SessionConfig {
    // No serializer configured = JdkSerializationRedisSerializer
    // Any class on the classpath can be deserialized from session data
    // Gadget chains in transitive dependencies enable RCE
}

Hardened: JSON Serialization with Type Allowlist

// HARDENED: JSON serialization with explicit type allowlist
@Configuration
@EnableRedisIndexedHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {

    @Bean("springSessionDefaultRedisSerializer")
    public RedisSerializer<Object> sessionSerializer() {
        ObjectMapper mapper = new ObjectMapper();

        // Enable type information in JSON (required for polymorphic deserialization)
        mapper.activateDefaultTyping(
            allowlistTypeValidator(),
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );

        // Configure for Spring Security types
        mapper.registerModule(new CoreJackson2Module());
        mapper.registerModule(new WebJackson2Module());
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        return new GenericJackson2JsonRedisSerializer(mapper);
    }

    private PolymorphicTypeValidator allowlistTypeValidator() {
        return BasicPolymorphicTypeValidator.builder()
            // Spring Security types
            .allowIfSubType("org.springframework.security.")
            // Spring Session types
            .allowIfSubType("org.springframework.session.")
            // Application domain types (add your own)
            .allowIfSubType("com.saas.platform.session.")
            // Java standard types needed for session data
            .allowIfSubType("java.util.")
            .allowIfSubType("java.time.")
            .allowIfSubType("java.lang.")
            // Deny everything else
            .build();
    }
}

The PolymorphicTypeValidator is the security boundary. Only classes matching the allowlist patterns can be deserialized from session data. A gadget chain that relies on org.apache.commons.collections.functors.InvokerTransformer is blocked because that class does not match any allowed prefix.

Key Namespace for Multi-Tenancy

@Configuration
@EnableRedisIndexedHttpSession
public class MultiTenantSessionConfig {

    @Bean
    public RedisIndexedSessionRepository sessionRepository(
            RedisOperations<String, Object> redisOperations) {

        RedisIndexedSessionRepository repository =
            new RedisIndexedSessionRepository(redisOperations);

        // Namespace keys to prevent collision with other services sharing Redis
        repository.setRedisKeyNamespace("saas:sessions");

        // Custom session ID generator (more entropy than default)
        repository.setSessionIdGenerator(() -> {
            byte[] bytes = new byte[32]; // 256 bits
            new SecureRandom().nextBytes(bytes);
            return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        });

        return repository;
    }
}

The resulting key structure:

saas:sessions:sessions:{sessionId}
saas:sessions:sessions:expires:{sessionId}
saas:sessions:expirations:{timestamp}
saas:sessions:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:{username}

The namespace prefix (saas:sessions) prevents key collision when multiple services share a Redis instance. The 256-bit session ID provides sufficient entropy to prevent brute-force session guessing (2^256 possible values vs the typical 128-bit default).

TTL Management

@Bean
public RedisIndexedSessionRepository sessionRepository(
        RedisOperations<String, Object> redisOperations) {

    RedisIndexedSessionRepository repository =
        new RedisIndexedSessionRepository(redisOperations);

    // Absolute maximum session lifetime (8 hours regardless of activity)
    repository.setDefaultMaxInactiveInterval(Duration.ofMinutes(30));

    // Redis keyspace notifications for expiry events
    // Requires Redis config: notify-keyspace-events Ex
    repository.setFlushMode(FlushMode.IMMEDIATE);

    return repository;
}

Two TTL mechanisms work together:

  1. Inactivity timeout (30 minutes). The session’s lastAccessedTime is updated on every request. If 30 minutes pass without a request, the session expires. Redis handles this via the EXPIRE command on the session key.

  2. Absolute timeout (custom). Regardless of activity, a session should not live forever. Implement with a custom SessionEventHttpSessionListenerAdapter that checks creationTime and forces expiry after 8 hours:

@Component
public class AbsoluteTimeoutSessionListener implements HttpSessionListener {

    private static final Duration ABSOLUTE_TIMEOUT = Duration.ofHours(8);

    @Override
    public void sessionCreated(HttpSessionEvent event) {
        // Store creation time as session attribute for checking
        event.getSession().setAttribute("_created", Instant.now().toEpochMilli());
    }
}

@Component
public class AbsoluteTimeoutFilter extends OncePerRequestFilter {

    private static final Duration ABSOLUTE_TIMEOUT = Duration.ofHours(8);

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain) throws Exception {

        HttpSession session = request.getSession(false);
        if (session != null) {
            Long created = (Long) session.getAttribute("_created");
            if (created != null) {
                Instant createdAt = Instant.ofEpochMilli(created);
                if (Instant.now().isAfter(createdAt.plus(ABSOLUTE_TIMEOUT))) {
                    session.invalidate();
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                    return;
                }
            }
        }
        chain.doFilter(request, response);
    }
}

The Verification

@SpringBootTest
@AutoConfigureMockMvc
class SessionSerializationTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private MockMvc mockMvc;

    @Test
    void sessionDataIsStoredAsJson() throws Exception {
        // Authenticate and establish a session
        MvcResult result = mockMvc.perform(post("/login")
                .param("username", "alice")
                .param("password", "correct-password"))
            .andExpect(status().is3xxRedirection())
            .andReturn();

        String sessionCookie = result.getResponse().getCookie("__Host-SESSION").getValue();

        // Read raw session data from Redis
        String sessionKey = "saas:sessions:sessions:" + sessionCookie;
        Map<Object, Object> sessionData = redisTemplate.opsForHash().entries(sessionKey);

        // Verify data is JSON (not binary JDK serialization)
        Object securityContext = sessionData.get(
            "sessionAttr:SPRING_SECURITY_CONTEXT");
        assertThat(securityContext.toString()).contains("\"@class\"");
        assertThat(securityContext.toString()).doesNotContain("\u0000"); // No null bytes (binary)
    }

    @Test
    void gadgetChainClassIsRejectedDuringDeserialization() {
        // Attempt to deserialize a known gadget chain class via session
        String maliciousJson = "{\"@class\":\"org.apache.commons.collections.functors.InvokerTransformer\"}";

        assertThatThrownBy(() -> {
            RedisSerializer<Object> serializer =
                (RedisSerializer<Object>) applicationContext.getBean("springSessionDefaultRedisSerializer");
            serializer.deserialize(maliciousJson.getBytes());
        }).isInstanceOf(InvalidTypeIdException.class);
    }

    @Test
    void sessionExpiresAfterInactivity() throws Exception {
        MvcResult result = mockMvc.perform(post("/login")
                .param("username", "alice")
                .param("password", "correct-password"))
            .andReturn();

        String sessionId = result.getResponse().getCookie("__Host-SESSION").getValue();

        // Verify session exists in Redis with TTL
        String sessionKey = "saas:sessions:sessions:" + sessionId;
        Long ttl = redisTemplate.getExpire(sessionKey, TimeUnit.SECONDS);
        assertThat(ttl).isBetween(1700L, 1800L); // ~30 minutes
    }
}

The second test is the critical security validation: it proves that the type allowlist blocks deserialization of gadget chain classes. If someone adds a vulnerable library to the classpath, the serialization layer rejects the dangerous type before instantiation occurs.