1
0
Files
jwt-auth-demo/docs/06-securitycontext-and-statelessness.md
asmhatre 4a8dab6739 Spring Security 7.1 JWT authentication on Spring Boot 4.1
Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/

- login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end
- HS256 and RS256 variants (RS256 publishes a real JWKS endpoint)
- the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison
- 11 documentation chapters under docs/, interlinked with the code
- docs/output/ is real captured output, regenerated by scripts/run-all.sh
- 13 passing tests pinning the 401-vs-403 contract and the CSRF failure

Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
2026-08-22 06:34:43 +00:00

5.4 KiB

06 — SecurityContext and statelessness

← HS256 vs RS256 · next: edge cases →

What "stateless" actually requires

Three separate settings, and setting only one of them is the usual mistake.

.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, 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:

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 uses RequestAttributeSecurityContextRepository, which survives a dispatch without ever touching a session — the right middle ground.

Always create the context, never mutate the shared one

// 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

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, step 20:

{
  "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:

// 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.

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.