Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix session fixation and session cookies in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Let Spring Security sign users in (formLogin or another built-in mechanism) and keep sessionFixation at changeSessionId, the default; a custom login endpoint must invoke the SessionAuthenticationStrategy itself. Set server.servlet.session.cookie.name=__Host-JSESSIONID, secure=true, http-only=true, same-site=lax, session.timeout=30m and tracking-modes=cookie, and end sessions 8 hours after sign-in.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Security 7.1.1, Spring Framework 7.0.9, Spring MVC, embedded Tomcat 11.0.26)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 30 KB
On this page

Steps

  1. Sign users in through Spring Security's own filters (formLogin, httpBasic, oauth2Login) and remove any sessionFixation(fixation -> fixation.none()); changeSessionId() is the default and calls HttpServletRequest.changeSessionId() at sign-in.
  2. If you have a custom login endpoint, for example one written like the reference's 'Storing the Authentication manually', call a SessionAuthenticationStrategy after authenticate() and before saving the SecurityContext, for example new ChangeSessionIdAuthenticationStrategy().onAuthentication(authentication, request, response). Added to this card's wrong implementation, that one call makes all 11 tests pass.
  3. Set the session cookie in application.properties: server.servlet.session.cookie.name=__Host-JSESSIONID, secure=true, http-only=true and same-site=lax, and leave cookie.domain and cookie.path unset so Tomcat sends Path=/ for an app at the root context.
  4. Set server.servlet.session.tracking-modes=cookie so session IDs are never accepted from or written into URLs.
  5. Set server.servlet.session.timeout to the idle limit (30 minutes here). Spring Boot hands Tomcat whole minutes, at least one.
  6. Record the sign-in time in the session from your success handler and add a filter before SecurityContextHolderFilter that invalidates the session once it is older than the absolute limit (8 hours here); neither Tomcat nor Spring Security ends a busy session.
  7. Add regression tests against a running server (@SpringBootTest with RANDOM_PORT: MockMvc keeps the session in a MockHttpSession and sends no session cookie) that replay the pre-login cookie after sign-in, read the Set-Cookie attributes, and move time past both limits.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/portal/impl/VulnerableSecurity.javaJava
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(requests -> requests.requestMatchers("/login").permitAll().anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/account", true))
            // Keep the visitor's session, and its ID, at sign-in.
            .sessionManagement(session -> session.sessionFixation(fixation -> fixation.none()));
    return http.build();
}

With IMPL=vulnerable, 5 of 11 tests fail (5 exploit tests).

The fix: a new session ID at sign-in

Fixed…/portal/impl/FixedSecurity.javaJava
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, Clock clock) throws Exception {
    http
            .authorizeHttpRequests(requests -> requests.requestMatchers("/login").permitAll().anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").successHandler(recordSignInTime(clock)))
            // The default since Servlet 3.1, shown for clarity: Spring Security's own sign-in filters
            // call HttpServletRequest.changeSessionId(), so the ID the browser held before sign-in
            // stops working. Code that signs users in by itself must do the same.
            .sessionManagement(session -> session.sessionFixation(fixation -> fixation.changeSessionId()))
            .addFilterBefore(new AbsoluteTimeout(clock), SecurityContextHolderFilter.class);
    return http.build();
}

With IMPL=fixed, 11 of 11 tests pass.

The fix: cookie attributes and idle timeout

Fixed…/main/resources/application-fixed.propertiesProperties
# Browsers accept a __Host- cookie only with Secure, Path=/ and no Domain. Tomcat sets Path=/ for
# an app at the root context; leave server.servlet.session.cookie.domain unset.
server.servlet.session.cookie.name=__Host-JSESSIONID
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
# Tomcat ends a session after 30 idle minutes (also Spring Boot's default, stated here).
server.servlet.session.timeout=30m
# Session IDs travel only in the cookie, never in URLs (;jsessionid=...).
server.servlet.session.tracking-modes=cookie

With IMPL=fixed, 11 of 11 tests pass.

The fix: absolute timeout

Fixed…/portal/impl/FixedSecurity.javaJava
static final Duration ABSOLUTE_TIMEOUT = Duration.ofHours(8);
static final String SIGNED_IN_AT = "signedInAt";

private static AuthenticationSuccessHandler recordSignInTime(Clock clock) {
    SimpleUrlAuthenticationSuccessHandler redirect = new SimpleUrlAuthenticationSuccessHandler("/account");
    return (request, response, authentication) -> {
        request.getSession().setAttribute(SIGNED_IN_AT, clock.instant());
        redirect.onAuthenticationSuccess(request, response, authentication);
    };
}

/**
 * Tomcat ends a session after 30 idle minutes (server.servlet.session.timeout), but nothing
 * ends a busy one. This filter does, 8 hours after sign-in, before Spring Security reads the
 * signed-in user from the session.
 */
static final class AbsoluteTimeout extends OncePerRequestFilter {

    private final Clock clock;

    AbsoluteTimeout(Clock clock) {
        this.clock = clock;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        HttpSession session = request.getSession(false);
        Instant signedInAt = session == null ? null : (Instant) session.getAttribute(SIGNED_IN_AT);
        if (signedInAt != null && !clock.instant().isBefore(signedInAt.plus(ABSOLUTE_TIMEOUT))) {
            session.invalidate();
        }
        chain.doFilter(request, response);
    }
}

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fix…/portal/impl/WrongSecurity.javaJava
@Controller
@Profile("wrong")
static class SignIn {

    private final AuthenticationManager authenticationManager;
    private final Clock clock;
    private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();

    SignIn(AuthenticationManager authenticationManager, Clock clock) {
        this.authenticationManager = authenticationManager;
        this.clock = clock;
    }

    @PostMapping("/login")
    String signIn(@RequestParam String username, @RequestParam String password,
            HttpServletRequest request, HttpServletResponse response) {
        Authentication authentication = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(username, password));
        SecurityContext context = securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authentication);
        securityContextHolderStrategy.setContext(context);
        // "Spring Security protects against session fixation by default." Not here: this stores
        // the user in the visitor's session, and the session keeps its ID.
        securityContextRepository.saveContext(context, request, response);
        request.getSession().setAttribute(SIGNED_IN_AT, clock.instant());
        return "redirect:/account";
    }

    @ExceptionHandler(AuthenticationException.class)
    ResponseEntity<String> refused() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unknown username or wrong password");
    }
}

With IMPL=wrong, 2 of 11 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/portal/ExploitTest.javaJava
/**
 * Exploit: a session ID that outlives sign-in, cookie attributes that expose the ID, and
 * sessions that never end.
 */
class ExploitTest extends BrowserTestSupport {

    @Test
    void sessionIdIssuedBeforeSignInDoesNotWorkAfterTheVictimSignsIn() {
        // The attacker opens the site and is given a session ID without signing in...
        Browser attacker = new Browser();
        attacker.get("/account", TYPED_URL);
        attacker.get("/login", SAME_ORIGIN_PAGE);
        // ...and the victim signs in on a browser that already holds it: a shared computer the
        // attacker used first, or a cookie written from a sibling subdomain (a __Host- cookie can't be).
        Browser victim = signIn(attacker.copy(), "alice");
        assertThat(signedInAs(victim)).isEqualTo("alice");
        assertThat(signedInAs(attacker)).as("the pre-login session ID opens Alice's account").isNotEqualTo("alice");
    }

    @Test
    void attackersOwnSessionPlantedInTheVictimsBrowserDoesNotBecomeTheVictims() {
        Browser bob = signIn("bob");
        Browser victim = signIn(bob.copy(), "alice"); // Bob plants his signed-in session cookie in Alice's browser
        assertThat(signedInAs(victim)).isEqualTo("alice");
        assertThat(signedInAs(bob)).as("Bob's session now opens Alice's account").isNotEqualTo("alice");
    }

    @Test
    void sessionCookieIsHttpOnlySoPageScriptsCannotReadIt() {
        SetCookie cookie = sessionCookie(signIn("alice"));
        assertThat(cookie.attributes()).as("Set-Cookie: %s", cookie.line()).containsKey("httponly");
    }

    @Test
    void sessionCookieIsSecureSoTheBrowserNeverSendsItOverPlainHttp() {
        SetCookie cookie = sessionCookie(signIn("alice"));
        assertThat(cookie.attributes()).as("Set-Cookie: %s", cookie.line()).containsKey("secure");
    }

    @Test
    void sessionCookieIsSameSiteLaxOrStrict() {
        SetCookie cookie = sessionCookie(signIn("alice"));
        assertThat(cookie.attributes().getOrDefault("samesite", "")).as("Set-Cookie: %s", cookie.line()).matches("(?i)lax|strict");
    }

    @Test
    void sessionCookieHasTheHostPrefixPathSlashAndNoDomain() {
        SetCookie cookie = sessionCookie(signIn("alice"));
        assertThat(cookie.name()).as("the session cookie's name").startsWith("__Host-");
        assertThat(cookie.attributes()).as("Set-Cookie: %s", cookie.line())
                .containsKey("secure").containsEntry("path", "/").doesNotContainKey("domain");
    }

    @Test
    void sessionLeftIdleFor31MinutesNoLongerWorks() {
        Browser alice = signIn("alice");
        advance(IDLE_FOR);
        assertThat(signedInAs(alice)).as("the session still works after %s idle minutes", IDLE_FOR).isNull();
    }

    @Test
    void sessionKeptBusyStopsWorking8HoursAfterSignIn() {
        Browser alice = signIn("alice");
        for (long t = POLL_EVERY; t <= BUSY_UNTIL; t += POLL_EVERY) {
            advance(POLL_EVERY);
            assertThat(signedInAs(alice)).as("signed out after %s busy minutes", t).isEqualTo("alice");
        }
        advance(PAST_ABSOLUTE_LIMIT - BUSY_UNTIL);
        assertThat(signedInAs(alice)).as("the session still works %s minutes after sign-in", PAST_ABSOLUTE_LIMIT).isNull();
    }
}

Pitfalls

  • Spring Security's session fixation protection runs inside its own sign-in filters. The reference's 'Storing the Authentication manually' sample authenticates and saves the SecurityContext without it, so an endpoint copied from it keeps the visitor's session ID.
  • The visitor has a session before sign-in without any code of yours: the CSRF token (HttpSessionCsrfTokenRepository) and the saved request for the page they wanted both live in the HttpSession.
  • Tomcat marks JSESSIONID HttpOnly by default and Secure only when it knows the request came over HTTPS; behind a TLS-terminating proxy that needs server.forward-headers-strategy (or a cloud platform Spring Boot detects), so set cookie.secure=true rather than rely on it. Tomcat sends no SameSite attribute unless one is configured.
  • Tomcat expires idle sessions (server.servlet.session.timeout, 30 minutes by default), but nothing in Tomcat or Spring Security ends a session that keeps being used.
  • Renaming the cookie to __Host-JSESSIONID only works when it stays Secure, Path=/ and without a Domain: setting server.servlet.session.cookie.domain, or serving the app under a context path, makes browsers reject it.
  • No scanner checked here reads the server.servlet.session.* properties or notices sessionFixation().none() or a custom login endpoint.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs session-cookies/java-spring does this for you).
  2. Against your own app, save the cookie a visitor gets on the sign-in page (curl -c jar -b jar https://your.app/login), sign in with the same jar and its _csrf token, and check that the sign-in response sets a new JSESSIONID (or your cookie's name) and that the old value no longer opens a protected page; check Secure, HttpOnly, SameSite and the name in that Set-Cookie line.
  3. Search the code for sessionFixation, SecurityContextRepository.saveContext and setAuthentication outside Spring Security's filters: scanners report none of them.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix session fixation and session cookies in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.lang.security.audit.cookie-missing-secure-flag.cookie-missing-secure-flagNo findings on any implementation. The rule matches response.addCookie(cookie) without cookie.setSecure(...); the session cookie is created by Tomcat from the server.servlet.session.cookie.* properties, and no rule in semgrep-rules (develop at 9bbf021) reads those properties, sessionFixation(...) or a login endpoint that skips the session strategy.Checked: Semgrep 1.180.0 (CE)
Semgrepjava.lang.security.audit.cookie-missing-httponly.cookie-missing-httponlyNo findings on any implementation, for the same reason: it looks at Cookie objects the application adds itself.Checked: Semgrep 1.180.0 (CE)
CodeQLjava/insecure-cookieNot expected to report anything: it reports Cookie objects passed to addCookie without setSecure(true). The container's session cookie, configured in properties, is not modelled. CodeQL has no Java query for session fixation (CWE-384 queries exist only for JavaScript and C#).Checked: github/codeql main at b5b8165; not executed
CodeQLjava/sensitive-cookie-not-httponlyNot expected to report anything: it follows cookies with sensitive names into addCookie or a Set-Cookie header written by the application.Checked: github/codeql main at b5b8165; not executed
Find Security Bugs (SpotBugs plugin)INSECURE_COOKIECookie without the secure flagIts 'code at risk' is a new Cookie(...) passed to addCookie without setSecure(true); for the session cookie it recommends <cookie-config> in web.xml, the equivalent of server.servlet.session.cookie.secure.Checked: Bug pattern listed on the Find Security Bugs site; not executed
Find Security Bugs (SpotBugs plugin)HTTPONLY_COOKIECookie without the HttpOnly flagSame shape as INSECURE_COOKIE, for HttpOnly.Checked: Bug pattern listed on the Find Security Bugs site; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Security 7.1.1, Spring Framework 7.0.9, Spring MVC, embedded Tomcat 11.0.26)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-security4.1.1 (Spring Security 7.1.1)
org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24)11.0.26
tools.jackson:jackson-bom (overrides Boot's 3.1.5)3.1.7
org.springframework.boot:spring-boot-starter-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the session-cookies/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs session-cookies/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 30 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • java OpenJDK 21.0.12.1 (Homebrew)
  • maven 3.9.16
  • springBoot 4.1.1
  • springSecurity 7.1.1
  • tomcat 11.0.26
  • junitJupiter 6.0.3
Matrix
fixed: 11 of 11 tests pass; vulnerable: 5 of 11 tests fail (5 exploit tests); wrong: 2 of 11 tests fail (2 exploit tests)
Test results
fixed 11/11 pass; vulnerable 5/11 fail (5 exploit tests: both fixation tests, SameSite, __Host- and the absolute timeout; HttpOnly, Secure and the idle timeout pass on Tomcat's defaults, Secure because the proxy reports HTTPS); wrong 2/11 fail (both fixation tests), offline after install
Dependency freshness
Spring Boot 4.1.1 and Spring Security 7.1.1 were published on 2026-08-20 and are the latest GA releases. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which have published advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7, so the pom overrides tomcat.version to 11.0.26 (2026-09-09) and jackson-bom.version to 3.1.7 (2026-09-21), as the other Spring cards do. OSV listed no advisories for the 74 resolved artifacts on 2026-10-10. Maven has no lockfile; the Boot parent and the two overrides fix every version.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Session Management Cheat Sheet (session ID renewal, cookie attributes, timeouts)
  2. Spring Security: Authentication persistence and session management (session fixation protection, storing the Authentication manually)
  3. Spring Boot: common application properties (server.servlet.session.*, server.forward-headers-strategy)
  4. Spring Boot: embedded web servers how-to (running behind a proxy)
  5. Apache Tomcat 11: the Cookie Processor (sameSiteCookies)
  6. MDN: Set-Cookie (Secure, HttpOnly, SameSite and the __Host- prefix)
  7. OWASP WSTG: Testing for Session Fixation

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.