1
0
Files
spring-security-demo/docs/01-inheritable-threadlocal.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

2.4 KiB

1. Why InheritableThreadLocal behaves differently with virtual threads

Next: Async + virtual threads →

Demo1PlainThreadLocal.java has no Spring in it at all. It exists to settle one question before Spring Security enters the picture: does InheritableThreadLocal actually behave differently once the thread on the other end is virtual?

The three cases

Every Thread copies the creating thread's InheritableThreadLocal values once, at construction time. That single sentence explains everything Spring Security's concurrency support has ever had to work around:

  • A fresh platform Thread picks up whatever was set on the thread that created it. Fine.
  • A pooled platform thread was constructed once, long ago, by the pool's internal thread factory. Every task submitted to it later runs on that same physical thread, so it keeps whatever InheritableThreadLocal value existed when the pool created the worker, not what the submitting thread had at submission time. docs/output/demo1.txt shows this directly: task 2 on a reused pool worker still reports request-B, not request-C, even though the caller updated the value in between.
  • A virtual thread is, in this respect, identical to the fresh-platform-thread case. Executors.newVirtualThreadPerTaskExecutor() and Thread.ofVirtual().start(...) both construct a brand new Thread object per task -- virtual threads are never pooled or reused the way platform worker threads are. So the "stale value from a reused thread" failure mode that made SecurityContextHolder.MODE_INHERITABLETHREADLOCAL dangerous with ThreadPoolTaskExecutor simply does not exist for virtual threads.

Why this matters for the rest of the repo

Spring Security's docs (and the original version of the blog post this repo supports) warn against MODE_INHERITABLETHREADLOCAL because of the pooled-thread case above. That warning is correct for ThreadPoolTaskExecutor. It stops being the relevant risk once spring.threads.virtual.enabled=true swaps the executor for a SimpleAsyncTaskExecutor backed by virtual threads -- there is no pool left to go stale. Chapter 2 verifies that directly against SecurityContextHolder.

Run it yourself: scripts/run-all.sh, or just demo1 from the output already captured in docs/output/demo1.txt.