1
0

Add virtual-thread, structured-concurrency demos for the Spring Security context propagation guide

Three verified programs (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency
versions) backing the ankurm.com post: InheritableThreadLocal across thread models,
@Async on a virtual-thread SimpleAsyncTaskExecutor (DelegatingSecurityContextExecutor
vs ContextPropagatingTaskDecorator), and StructuredTaskScope.fork() propagation.
Captured console output and docs chapters included.
This commit is contained in:
2026-08-24 21:48:47 +05:30
commit b7244ff9a2
13 changed files with 543 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
# 1. Why InheritableThreadLocal behaves differently with virtual threads
[Next: Async + virtual threads →](02-async-virtual-threads.md)
`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](02-async-virtual-threads.md)
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`](output/demo1.txt).

View File

@@ -0,0 +1,67 @@
# 2. @Async, DelegatingSecurityContextExecutor, and virtual threads on Boot 4.1
[← Prev: InheritableThreadLocal](01-inheritable-threadlocal.md) | [Next: Structured concurrency →](03-structured-concurrency.md)
`Demo2AsyncVirtualThreads.java` reproduces the exact executor bean Spring Boot 4.1 wires up
when you set `spring.threads.virtual.enabled=true`: a `SimpleAsyncTaskExecutor` with
`setVirtualThreads(true)`. That bean backs `@Async`, MVC async request handling, and WebFlux's
blocking-execution support. It is not a `ThreadPoolTaskExecutor` and never has a fixed pool of
workers to reuse -- see [Chapter 1](01-inheritable-threadlocal.md) for why that matters.
Four scenarios, same question each time: does the async task see the `Authentication` that was
active on the calling thread? Full output in [`docs/output/demo2.txt`](output/demo2.txt).
## A) Default mode, unwrapped executor -- loses it
`SecurityContextHolder`'s default strategy, `MODE_THREADLOCAL`, does not travel to any new
thread, virtual or not. This is the exact symptom reported against Spring Security as
[gh-15040](https://github.com/spring-projects/spring-security/issues/15040): swap in a raw
virtual-thread executor and `@Async` methods start throwing `AccessDeniedException` because
`SecurityContextHolder.getContext().getAuthentication()` is `null`.
## B) MODE_INHERITABLETHREADLOCAL, unwrapped executor -- works
This is the finding from Chapter 1 applied to Spring Security directly. Because the virtual
thread the executor spins up is fresh every time, `MODE_INHERITABLETHREADLOCAL` propagates the
context correctly with **zero extra wrapping code**. The reference docs' warning against this
mode predates virtual threads and is about pooled platform threads specifically -- it does not
apply to this executor shape. This is still a global JVM-wide setting, so weigh that against the
next two options, which are scoped to one executor bean.
## C) DelegatingSecurityContextExecutor -- still works, unconditionally
`DelegatingSecurityContextExecutor` doesn't rely on thread-local inheritance at all -- it wraps
the submitted `Runnable`, and the wrapper explicitly calls
`SecurityContextHolder.setContext(...)` / `clearContext()` around the delegate's `run()`,
wherever that `run()` happens to execute. That is why it has worked, unchanged, since long
before virtual threads existed, and why it is still the correct choice for library code that
cannot assume the application has set `MODE_INHERITABLETHREADLOCAL` globally.
## D) ContextPropagatingTaskDecorator -- the mechanism that's actually new
This is the one that did not exist when the [original version of this
post](https://ankurm.com/spring-security-context-propagation-complete-guide/) went up.
Spring Security 6.5 (GA 2025-05-19) added `SecurityContextHolderThreadLocalAccessor`, which
self-registers with Micrometer's `ContextRegistry` via `ServiceLoader` the moment
`io.micrometer:context-propagation` is on the classpath -- no bean, no configuration. Spring
Framework's `ContextPropagatingTaskDecorator` (since 6.1) uses that registry to snapshot and
restore every registered `ThreadLocalAccessor` around a task. Set it as the executor's task
decorator and `@Async` methods get the `SecurityContext` back **without any
`DelegatingSecurityContext*` wrapper at all** -- and the same decorator simultaneously restores
MDC and tracing context, which the `Delegating*` classes never touched.
```java
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
executor.setVirtualThreads(true);
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
```
Spring Security's own [Concurrency Support
page](https://docs.spring.io/spring-security/reference/features/integrations/concurrency.html)
still only documents the `Delegating*` family as of 7.1.1 -- this pattern is real and shipped,
just not yet reflected in that page.
`io.micrometer:context-propagation` is already on the classpath of any Boot 4.1 app that pulls
in `micrometer-observation` (actuator, tracing, or `spring-boot-starter-micrometer-*`). If your
app doesn't have a Micrometer dependency anywhere, add
`io.micrometer:context-propagation:1.2.1` (the version Boot 4.1.1's BOM manages) explicitly.

View File

@@ -0,0 +1,61 @@
# 3. StructuredTaskScope and SecurityContext
[← Prev: Async + virtual threads](02-async-virtual-threads.md)
`Demo3StructuredConcurrency.java` asks the Chapter 2 question again, but for
`StructuredTaskScope` (JEP 505, fifth preview in JDK 25 -- still preview through the JDK 26
sixth preview per JEP 525, so every example here needs `--enable-preview`). A `fork()` call
starts a brand new virtual thread for the subtask, same as the executors in Chapter 2, so the
Chapter 1 finding applies here too. Full output in
[`docs/output/demo3.txt`](output/demo3.txt).
## What the JEP actually promises
JEP 525's text is explicit about one kind of context and silent about another:
> Subtasks forked in a scope inherit `ScopedValue` bindings.
That is a real, specified guarantee -- and it says nothing about `ThreadLocal`. Spring
Security's `SecurityContextHolder` is a `ThreadLocal`/`InheritableThreadLocal`, not a
`ScopedValue`. Nothing in the structured concurrency API changes that, and scenario A below
proves it: a plain `scope.fork(...)` with the default `MODE_THREADLOCAL` strategy loses the
`Authentication` exactly like the unwrapped executor in Chapter 2 did.
## Four scenarios
- **A) Plain fork, MODE_THREADLOCAL** -- lost. The default `SecurityContextHolder` strategy
isn't inherited by anything, structured concurrency included.
- **B) Plain fork, MODE_INHERITABLETHREADLOCAL** -- propagates. Same reasoning as Chapter 2,
scenario B: `fork()`'s subtask thread is a fresh virtual thread, so inheritance at
construction time works and there is no pooled-thread staleness risk.
- **C) Manual capture-and-restore around the forked `Callable`** -- propagates, and does not
depend on the global strategy mode at all:
```java
SecurityContext captured = SecurityContextHolder.getContext();
Callable<String> task = () -> {
SecurityContextHolder.setContext(captured);
try { return doWork(); }
finally { SecurityContextHolder.clearContext(); }
};
scope.fork(task);
```
This is the safest pattern for a `StructuredTaskScope` used inside library code, the same
way `DelegatingSecurityContextExecutor` is the safest pattern for an `Executor`: it works
regardless of what the surrounding application has set `SecurityContextHolder`'s strategy to.
- **D) `ContextSnapshot.wrap(...)` around the forked `Callable`** -- the Chapter 2 mechanism
applied to `fork()` instead of `execute()`. Because `SecurityContextHolderThreadLocalAccessor`
is already registered with Micrometer's `ContextRegistry`, `ContextSnapshotFactory.builder()
.build().captureAll()` picks up the current `SecurityContext` (and MDC, and tracing context)
in one call, and `.wrap(callable)` restores all of them inside the subtask. This is the
version worth reaching for once you have more than the `SecurityContext` to carry across the
scope boundary.
## The practical takeaway
`StructuredTaskScope` does not give `SecurityContextHolder` anything for free. If your
`fork()`ed subtasks need to call secured services, wrap them explicitly -- option C if you
want zero new dependencies, option D if `context-propagation` is already on the classpath and
you have other thread-locals to carry along too.

5
docs/output/demo1.txt Normal file
View File

@@ -0,0 +1,5 @@
=== Demo 1: InheritableThreadLocal across thread models ===
fresh platform thread sees: request-A
pool thread, task 1, sees: request-B
pool thread, task 2 (reused), sees: request-B <-- stale, not request-C
fresh virtual thread sees: request-D

6
docs/output/demo2.txt Normal file
View File

@@ -0,0 +1,6 @@
=== Demo 2: @Async-style virtual thread executor + SecurityContext ===
A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): NO AUTHENTICATION (lost) [VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-1]
B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): authenticated as bob [VirtualThread[#26,vt-1]/runnable@ForkJoinPool-1-worker-1]
C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual): authenticated as carol [VirtualThread[#27,vt-1]/runnable@ForkJoinPool-1-worker-1]
SecurityContextHolderThreadLocalAccessor present: true
D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper: authenticated as dave [VirtualThread[#28,vt-1]/runnable@ForkJoinPool-1-worker-1]

5
docs/output/demo3.txt Normal file
View File

@@ -0,0 +1,5 @@
=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===
A) plain fork, MODE_THREADLOCAL: NO AUTHENTICATION (lost)
B) plain fork, MODE_INHERITABLETHREADLOCAL: authenticated as frank
C) manual capture/restore: authenticated as grace
D) ContextSnapshot.wrap: authenticated as heidi