Full companion repo for the ankurm.com post "Spring Security Context Propagation: The Complete Guide" -- every code example the post discusses now has a corresponding runnable, verified demo (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency versions), not just the virtual-thread/structured-concurrency sections: - Demo1PlainThreadLocal: InheritableThreadLocal across thread models (no Spring) - Demo2AsyncVirtualThreads: @Async on a virtual-thread SimpleAsyncTaskExecutor (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator) - Demo3StructuredConcurrency: StructuredTaskScope.fork() propagation - Demo4ExecutorWrapping: DelegatingSecurityContextExecutorService/Executor/ AsyncTaskExecutor on a classic pooled platform-thread executor -- the post's "Using @Async" / "Using ExecutorService" / "Using CompletableFuture" sections - Demo5ReactiveContext: ReactiveSecurityContextHolder vs. ThreadLocal across a Reactor scheduler hop -- the post's WebFlux/getProfile() section - Demo6ScheduledSystemIdentity: DelegatingSecurityContextTaskScheduler's actual per-call capture semantics (confirmed via bytecode before writing the demo) and the createSystemContext() pattern -- the post's scheduled-tasks section - Demo7ServletFilterPersistence: SecurityContextHolderFilter (load-only) vs. SecurityContextPersistenceFilter (load+auto-save), against real filter instances and a real HttpSession -- the post's servlet-environment section - SecurityContextPropagationContractTest: 10 JUnit tests pinning the above as assertions instead of printed lines, including a TestSecurityContextHolder-based test reproducing the post's own "Testing Security Context Propagation" section Thirteen edge cases discovered along the way are indexed in docs/08 with links into the chapter that reproduces each one -- a reused pool worker NOT leaking under the Delegating* wrappers (unlike Demo1's InheritableThreadLocal), the common ForkJoinPool trap, why there's no DelegatingSecurityContextStructuredTaskScope and never will be, a real NullPointerException from Reactor's map() hit while writing the reactive test, per-call (not per-construction) context capture in DelegatingSecurityContextTaskScheduler, and the precise load-vs-save split between the two servlet filters, among others. docs/01-08 are numbered, cross-linked chapters with prev/next navigation; README indexes all demos, chapters, captured output, and the edge-case list. scripts/run-all.sh regenerates every docs/output/*.txt and the test suite output from one command.
4.3 KiB
7. SecurityContextHolderFilter vs. SecurityContextPersistenceFilter
← Prev: Scheduled tasks | Next: Testing contract + edge-case index →
The post's "Security Context Propagation in Servlet Environment" section makes a specific,
checkable claim: since Spring Security 6.0, SecurityContextHolderFilter replaced
SecurityContextPersistenceFilter as the default, and the two behave differently in a way
that matters -- the old filter auto-saved the context at the end of the request, the new one
only loads. Demo7ServletFilterPersistence.java runs both real filter classes against a real
HttpSession (via Spring Test's MockHttpServletRequest/MockHttpServletResponse, no
servlet container) to confirm it directly rather than restate the reference docs. Full output
in docs/output/demo7.txt.
Load vs. save, proven separately
Scenario A runs SecurityContextPersistenceFilter: the simulated controller sets an
Authentication on SecurityContextHolder mid-chain, and once the filter's doFilter
returns, the session already contains it:
A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: true
Scenario B is the identical setup against SecurityContextHolderFilter, the Security 6+
default:
B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: false
Nothing was written to the session. This is requireExplicitSave's default behavior made
concrete: setting SecurityContextHolder.setContext(...) inside request processing does not,
by itself, persist anything past the current request under the Security-6-default filter.
The fix, proven too
Scenario C repeats B but adds one line inside the simulated chain --
repository.saveContext(ctx, request, response) -- the exact workaround the post recommends
for custom pre-authentication filters that set the context directly:
C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: true
Edge case: "only loads, never saves" describes the save side, not the load side
It's easy to over-read "only loads, never saves" as "does almost nothing." Scenario D seeds a
session with a context (simulating what an earlier request's explicit saveContext(...)
would have left behind) and confirms SecurityContextHolderFilter still loads it correctly on
a subsequent request through the same session:
D) SecurityContextHolderFilter, context already saved in an existing session, next request: authenticated as dave
The filter's whole job on the read side is unchanged; the only thing Security 6 removed is the automatic write at the end.
Edge case: no session, no prior save -- not an error
Scenario E runs a completely fresh request through SecurityContextHolderFilter with no
existing session and nothing set anywhere:
EDGE) brand-new request, no prior session, nothing set: NO AUTHENTICATION (empty context, not an error)
SecurityContextHolder.getContext() never returns null -- Spring Security's
SecurityContextHolderStrategy always hands back an empty SecurityContext object whose
getAuthentication() is null, rather than a null context itself. Code that checks
context == null to detect "nobody's authenticated" is checking the wrong thing; check
context.getAuthentication() == null instead.
Why this matters more than it looks
requireExplicitSave(true) (shown in the post's WebSecurityConfig) is not something you
turn on -- it has been the default since Security 6.0, and OpenRewrite ships a migration
recipe specifically to remove the explicit call as dead weight when upgrading to 6.0. The
behavior it names, though, is exactly what scenarios A/B/C above measure: the old auto-save
wrote to the session on every request regardless of whether the context had actually changed,
which was wasteful and made intent ambiguous; the new default only writes when something
explicitly asks it to. The framework's own authentication filters (form login, basic auth,
OAuth2 login) already call saveContext(...) after a successful login, so this rarely bites
application code -- it becomes a real bug only in code that calls
SecurityContextHolder.setContext(...) directly, outside that flow, exactly the custom
pre-authentication-filter case the post calls out.