1
0
Files
spring-security-demo/README.md
Ankur 9f950bffa9 Add every example from the post, plus edge cases, to the companion repo
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.
2026-08-24 16:49:30 +00:00

8.4 KiB

spring-security-demo

Companion repo for Spring Security Context Propagation: The Complete Guide on ankurm.com. 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:

Doc chapters

Numbered, cross-linked, each with prev/next navigation at the top:

  1. InheritableThreadLocal across thread models
  2. @Async, DelegatingSecurityContextExecutor, and virtual threads
  3. StructuredTaskScope and SecurityContext
  4. Executor, ExecutorService, and AsyncTaskExecutor wrapping
  5. ReactiveSecurityContextHolder and Reactor Context
  6. DelegatingSecurityContextTaskScheduler and the synthetic system identity
  7. SecurityContextHolderFilter vs. SecurityContextPersistenceFilter
  8. 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.