# 06 — SecurityContext and statelessness [← HS256 vs RS256](05-hs256-vs-rs256.md) · [next: edge cases →](07-edge-cases.md) ## What "stateless" actually requires Three separate settings, and setting only one of them is the usual mistake. ```java .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .securityContext(context -> context .securityContextRepository(new NullSecurityContextRepository())) .csrf(csrf -> csrf.disable()) ``` `SessionCreationPolicy.STATELESS` stops **Spring Security** from creating or using a session. It does not stop your application: any `request.getSession()`, any `@SessionAttributes`, any Spring Session integration still creates one. And it does not stop the `SecurityContextRepository` from being consulted. `NullSecurityContextRepository` closes the second half. Without it the default is `DelegatingSecurityContextRepository(RequestAttributeSecurityContextRepository, HttpSessionSecurityContextRepository)` — so a `SecurityContext` you save goes into an `HttpSession`, and a session cookie appears in a response you believed was stateless. Verify rather than assume: the transcript prints `Set-Cookie` if one appears. In [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt), none does. ## `SecurityContextHolderFilter` and explicit save Spring Security 6 replaced `SecurityContextPersistenceFilter` with `SecurityContextHolderFilter`. The difference is one line of behaviour: | | loads context | saves context | |---|---|---| | `SecurityContextPersistenceFilter` (legacy) | yes | **automatically**, at the end of the request | | `SecurityContextHolderFilter` (6.0+ default) | yes | **no — you must call `saveContext`** | Anything that authenticates a request must now say so explicitly: ```java SecurityContext context = this.contextHolderStrategy.createEmptyContext(); context.setAuthentication(authentication); this.contextHolderStrategy.setContext(context); this.contextRepository.saveContext(context, request, response); // <-- easy to forget ``` For a genuinely stateless API `saveContext` on a `NullSecurityContextRepository` is a no-op, so omitting it appears to work — until an `ERROR` dispatch, a `FORWARD`, or an async re-dispatch clears the `ThreadLocal` and the principal vanishes on `/error`. [`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) uses `RequestAttributeSecurityContextRepository`, which survives a dispatch without ever touching a session — the right middle ground. ## Always create the context, never mutate the shared one ```java // wrong - mutates a context that may be shared SecurityContextHolder.getContext().setAuthentication(auth); // right SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(auth); SecurityContextHolder.setContext(context); ``` The first form has been discouraged since 5.7 and is a real race in multi-threaded handling. ## Use the strategy, not the static methods ```java private final SecurityContextHolderStrategy contextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); ``` `SecurityContextHolder`'s static methods delegate to whatever strategy is installed, but capturing the strategy once is what the framework's own filters do, and it is the only form that keeps working when the application swaps in a delegating strategy — the usual reasons being observability, tenant propagation, or structured concurrency. ## The thread boundary `SecurityContextHolder` is a `ThreadLocal`. It does not cross threads. `GET /api/async-demo` proves it — from the [transcript](output/curl-transcript-hs256.txt), step 20: ```json { "onRequestThread" : "root", "onPlainExecutor" : "null (context did not cross the thread)", "onDelegatingExecutor" : "root" } ``` Same request, same instant, three answers. The middle one is what a `@Async` method, a plain `CompletableFuture.supplyAsync`, or a raw executor sees. Fixes, in order of scope: ```java // one executor new DelegatingSecurityContextExecutorService(Executors.newVirtualThreadPerTaskExecutor()); // one task new DelegatingSecurityContextRunnable(task); new DelegatingSecurityContextCallable<>(task); // the whole application - context inherited by child threads SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); ``` `MODE_INHERITABLETHREADLOCAL` is the tempting one and the wrong one for a servlet container: threads are **pooled**, so "child" is whatever thread the pool happens to spawn, and a context can be inherited by a task belonging to a different request. Wrap executors instead. For `@Async` specifically, Spring Security's `DelegatingSecurityContextAsyncTaskExecutor` wraps the task executor; ankurm.com has a [dedicated guide to context propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/). ## Virtual threads Boot 4.1 on JDK 25 makes `spring.threads.virtual.enabled=true` unremarkable. `ThreadLocal` works on a virtual thread exactly as on a platform thread, so the `SecurityContext` behaves identically. The one thing that changes: virtual threads are *not* pooled, so the cross-request leak from a stale `ThreadLocal` is far less likely — which is a reason to be *more* careful, not less, because the bug becomes rarer and harder to reproduce rather than absent. Clear the context on the failure path regardless.