Moves the existing virtual-thread/context-propagation project into context-propagation/ and adds method-security/ for the Spring Security 7 method-security article: nine runnable demos, fourteen assertions, and every transcript the article quotes, regenerated by scripts/run-all.sh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
context-propagation
Companion module for Spring Security Context Propagation: The Complete Guide on ankurm.com.
Part of spring-security-demo; the sibling module is
method-security, which covers what @PreAuthorize does with
the Authentication once it has reached the right thread -- and the cases where it silently
does nothing.
Eight small, dependency-light programs and one JUnit test suite that answer one question each:
does a Spring Security SecurityContext survive a specific thread, scheduler, or request
hand-off? Every scenario sets an Authentication somewhere and checks whether the other side
of the hand-off can see it -- against real executors, a real Reactor pipeline, a real
TaskScheduler, and real servlet filter classes (via Spring Test's mock request/response, no
running server needed).
Every example the post shows -- @Async, ExecutorService, CompletableFuture, virtual
threads, StructuredTaskScope, ReactiveSecurityContextHolder/WebFlux,
DelegatingSecurityContextTaskScheduler and scheduled tasks, and the
SecurityContextHolderFilter/SecurityContextPersistenceFilter servlet distinction -- has a
runnable demo here, plus edge cases the post doesn't have room for. See the edge-case
index for the full list with links.
Verified versions
| Component | Version |
|---|---|
| JDK | 25 (Temurin 25.0.4.1+1), LTS, GA 2025-09-16 |
| Spring Boot (reference target) | 4.1.1 |
| Spring Framework | 7.0.9 |
| Spring Security | 7.1.1 |
| Spring Security Test | 7.1.1 |
io.micrometer:context-propagation |
1.2.1 (as managed by Boot 4.1.1's BOM) |
| Reactor Core / Reactor Test | 3.8.7 (as managed by Boot 4.1.1's reactor-bom 2025.0.7) |
jakarta.servlet-api |
6.1.0 |
| JUnit Jupiter | 6.0.3 |
| AssertJ | 3.27.7 |
StructuredTaskScope is a preview API on JDK 25 (JEP 505, fifth preview) and remains
preview through JDK 26 (JEP 525, sixth preview) -- every build/run/test command below needs
--enable-preview.
Quickstart
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
javac --release 25 --enable-preview -cp "$(cat cp.txt)" -d target/classes $(find src/main -name '*.java')
java --enable-preview -cp "target/classes:$(cat cp.txt)" com.ankurm.vt.Demo1PlainThreadLocal
Or just run everything -- all seven demos plus the test suite -- and regenerate the captured
output: scripts/run-all.sh. To run only the JUnit contract tests: mvn test (the
--enable-preview flag is already wired into pom.xml's surefire argLine, no extra flags
needed).
What each demo shows
| Demo | Question | Chapter |
|---|---|---|
Demo1PlainThreadLocal |
Does InheritableThreadLocal behave differently for a pooled platform thread vs. a fresh virtual thread? (No Spring.) |
docs/01 |
Demo2AsyncVirtualThreads |
Does the Boot-4.1-style virtual-thread @Async executor propagate SecurityContext, and what four fixes change? |
docs/02 |
Demo3StructuredConcurrency |
Does a StructuredTaskScope.fork() subtask see the parent's SecurityContext? |
docs/03 |
Demo4ExecutorWrapping |
Do DelegatingSecurityContextExecutorService/Executor/AsyncTaskExecutor propagate context on a classic pooled platform-thread executor, and does a reused worker leak between tasks the way InheritableThreadLocal did in Demo 1? |
docs/04 |
Demo5ReactiveContext |
Does ReactiveSecurityContextHolder survive a scheduler hop that kills plain ThreadLocal-based SecurityContextHolder? |
docs/05 |
Demo6ScheduledSystemIdentity |
What does DelegatingSecurityContextTaskScheduler actually capture, and when -- and how does the post's createSystemContext() pattern fix the fact that there's no real caller to propagate from? |
docs/06 |
Demo7ServletFilterPersistence |
Does SecurityContextHolderFilter really never save, while SecurityContextPersistenceFilter does -- proven against real filter instances and a real HttpSession? |
docs/07 |
SecurityContextPropagationContractTest (JUnit, src/test) |
Same ten claims above, pinned as assertions instead of printed lines; includes a TestSecurityContextHolder-based test reproducing the post's own "Testing Security Context Propagation" section |
docs/08 |
Endpoints / entry points
There's no web server in this repo (see the top of this file), so "entry points" means: every
class above has a runnable main(), and the whole suite runs end to end via
scripts/run-all.sh. Demo5ReactiveContext reproduces the post's /profile
(ReactiveSecurityContextHolder) behavior as a plain Mono chain rather than a bound HTTP
route, and Demo7ServletFilterPersistence reproduces the filter chain's request-scoped
behavior against MockHttpServletRequest/MockHttpServletResponse rather than a bound
servlet container -- both keep the "no web server, no HTTP" property the original three demos
established, so the whole repo still runs in well under a second with zero open ports.
Captured output
Every number and log line in the blog post traces back to one of these, produced by
scripts/run-all.sh, not retyped:
- docs/output/demo1.txt
- docs/output/demo2.txt
- docs/output/demo3.txt
- docs/output/demo4.txt
- docs/output/demo5.txt
- docs/output/demo6.txt
- docs/output/demo7.txt
- docs/output/tests.txt --
mvn testsurefire summary for the ten contract tests
Doc chapters
Numbered, cross-linked, each with prev/next navigation at the top:
- InheritableThreadLocal across thread models
- @Async, DelegatingSecurityContextExecutor, and virtual threads
- StructuredTaskScope and SecurityContext
- Executor, ExecutorService, and AsyncTaskExecutor wrapping
- ReactiveSecurityContextHolder and Reactor Context
- DelegatingSecurityContextTaskScheduler and the synthetic system identity
- SecurityContextHolderFilter vs. SecurityContextPersistenceFilter
- Testing contract + edge-case index
Edge cases
Thirteen reproducible edge cases were found building this repository -- pooled-worker leaks
the Delegating* classes don't have, the common ForkJoinPool trap, why
MODE_INHERITABLETHREADLOCAL is a JVM-wide instrument, why there's no
DelegatingSecurityContextStructuredTaskScope and never will be, a real NullPointerException
hit writing the reactive test, ReactiveSecurityContextHolder completing empty rather than
erroring, per-call (not per-construction) context capture in
DelegatingSecurityContextTaskScheduler, why a synthetic SYSTEM principal isn't
"anonymous", and the precise load-vs-save split between the two servlet filters. Full list,
each with the chapter that reproduces it: docs/08 § Edge-case
index.
The one-line summary of all eight chapters
SecurityContextHolder is a ThreadLocal. Nothing about virtual threads, structured
concurrency, reactive streams, schedulers, or servlet filters changes that fact -- what changes
between them is how (or whether) anything carries that ThreadLocal's value across the
boundary each one introduces. Virtual threads are never pooled, so
MODE_INHERITABLETHREADLOCAL's old danger (stale context on a reused pool worker) doesn't
apply to them, but it's still a JVM-wide setting. The Delegating* wrapper family solves the
same pooled-worker problem by a completely different mechanism -- explicit push/pop per task,
never thread inheritance -- which is why it has worked, unchanged, since long before virtual
threads existed. Reactive code doesn't have a ThreadLocal-compatible thread to begin with, so
ReactiveSecurityContextHolder uses Reactor's own Context instead. Scheduled tasks have no
caller at all, so the fix isn't propagation, it's minting an identity. And the servlet filter
that used to auto-save the context for you was replaced by one that only loads -- a change
worth knowing about before it's the reason a custom filter's write silently doesn't survive to
the next request.
License
MIT -- see LICENSE.