1
0

Split into per-article modules and add the method-security module

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
This commit is contained in:
2026-08-25 02:01:29 +00:00
parent 9f950bffa9
commit 5e9e7f1b12
65 changed files with 4088 additions and 119 deletions

View File

@@ -0,0 +1,39 @@
# 1. Why InheritableThreadLocal behaves differently with virtual threads
[Next: Async + virtual threads &rarr;](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
[&larr; Prev: InheritableThreadLocal](01-inheritable-threadlocal.md) | [Next: Structured concurrency &rarr;](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
[&larr; Prev: Async + virtual threads](02-async-virtual-threads.md) | [Next: Executor/ExecutorService wrapping &rarr;](04-executor-wrapping.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.

View File

@@ -0,0 +1,65 @@
# 4. Executor, ExecutorService, and AsyncTaskExecutor wrapping
[&larr; Prev: Structured concurrency](03-structured-concurrency.md) | [Next: Reactive context &rarr;](05-reactive-context.md)
Chapters 1&ndash;3 are all about virtual threads and structured concurrency, which came later.
`Demo4ExecutorWrapping.java` goes back to the baseline the post's "Using @Async", "Using
ExecutorService", and "Using CompletableFuture" sections describe: a **fixed platform-thread
pool** (`ThreadPoolExecutor`, `ThreadPoolTaskExecutor`), the shape almost every Spring app used
before `spring.threads.virtual.enabled` existed, and the exact pooled-thread danger
[Chapter 1](01-inheritable-threadlocal.md) demonstrated for `InheritableThreadLocal`. Full
output in [`docs/output/demo4.txt`](output/demo4.txt).
## The three wrapper classes the post names
- **`DelegatingSecurityContextExecutorService`** wraps an entire `ExecutorService` --
`execute()`, `submit()`, `invokeAll()`, `invokeAny()` all go through the wrapper. This is
the fix for the post's `TaskExecutionService` ("Using ExecutorService") example.
- **`DelegatingSecurityContextExecutor`** wraps a plain `Executor` and is what you hand to
`CompletableFuture.supplyAsync(supplier, executor)` -- the fix for
`CompletableFutureService` ("Using CompletableFuture"). The common `ForkJoinPool` that
`CompletableFuture.supplyAsync(supplier)` uses when you don't supply an executor never
propagates context; scenario C2 in the output shows that directly.
- **`DelegatingSecurityContextAsyncTaskExecutor`** wraps Spring's own
`AsyncTaskExecutor`/`TaskExecutor` abstraction -- the type `AsyncConfigurer#getAsyncExecutor()`
actually returns, and the object Spring's `@Async` infrastructure calls `execute()`/`submit()`
on. This is the fix for the post's `AsyncConfig` example.
All three extend the same mechanism [Chapter 2](02-async-virtual-threads.md) already
described: wrap the submitted `Runnable`/`Callable`, capture `SecurityContextHolder.getContext()`
once (at wrap time, not at thread-construction time), and push/pop it around the delegate's
execution on whatever thread that turns out to be.
## The edge case Chapter 1 sets up and this chapter resolves
Chapter 1's whole point was that a **reused pool worker** keeps whatever
`InheritableThreadLocal` value existed when the pool created it, not what the submitting
thread had at submission time -- that's why `MODE_INHERITABLETHREADLOCAL` is dangerous with a
fixed thread pool. The `EDGE` scenario in this demo asks the same question of the
`Delegating*` wrappers, and the answer is the opposite:
```
EDGE) task 1 on possibly-reused worker: authenticated as carol-task1
EDGE) task 2, same pool, different caller context: authenticated as dave-task2 <-- correct, NOT stale
```
Two tasks submitted through `DelegatingSecurityContextExecutorService` to the *same* two-worker
pool, with the caller's `SecurityContextHolder` context changed in between, each see their
**own** context -- never the other task's. That's because the wrapper captures context per
submission (inside `wrap()`, called synchronously from `execute()`/`submit()`), not once per
worker thread the way thread-local inheritance does. This is the actual reason the
`Delegating*` family predates virtual threads by years and is still correct on a fixed pool:
it never depended on thread identity in the first place.
[Chapter 8](08-testing-contract.md) pins this exact contrast as a JUnit assertion
(`delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask`), not just
a printed line.
## Practical note: which one do you actually need?
If you already have an `AsyncConfigurer` returning a `ThreadPoolTaskExecutor`, wrap it with
`DelegatingSecurityContextAsyncTaskExecutor` and nothing else changes -- `@Async` keeps working
as written. If you're calling `CompletableFuture.supplyAsync(...)` without an explicit executor
anywhere in the codebase, that's the common-`ForkJoinPool` trap in scenario C2; the fix is
always to supply a `DelegatingSecurityContextExecutor`-wrapped executor, never to reach for
`MODE_INHERITABLETHREADLOCAL` as a global patch for one call site.

View File

@@ -0,0 +1,90 @@
# 5. ReactiveSecurityContextHolder and Reactor Context
[&larr; Prev: Executor/ExecutorService wrapping](04-executor-wrapping.md) | [Next: Scheduled tasks &rarr;](06-scheduled-tasks.md)
Every other demo in this repository asks "does the *thread* on the other side of a hand-off
see the `SecurityContext`?" `Demo5ReactiveContext.java` asks a different question, because
WebFlux doesn't have a thread on the other side of anything in the sense the rest of this
repo cares about -- a reactive chain hops between scheduler threads as operators execute, and
none of those threads is dedicated to one request. Full output in
[`docs/output/demo5.txt`](output/demo5.txt).
## Why ThreadLocal genuinely cannot work here
Scenarios A and B make the failure concrete rather than asserted. A sets
`SecurityContextHolder` (a `ThreadLocal`) on the calling thread and reads it back
immediately -- works, because nothing has moved threads yet. B does the identical setup, but
inserts a single `.publishOn(Schedulers.boundedElastic())` between the write and the read --
exactly what a real WebFlux event loop does routinely between operators -- and the context is
gone:
```
A) SecurityContextHolder (ThreadLocal), no scheduler hop: authenticated as alice
B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: NO AUTHENTICATION (lost)
```
This is the actual mechanism behind the post's line "`ThreadLocal` doesn't work because
operations jump between threads" -- not a general reactive-programming caveat, a specific,
reproducible failure with a specific operator.
## ReactiveSecurityContextHolder: the fix, and why it survives the same hop
`ReactiveSecurityContextHolder.getContext()` doesn't read a `ThreadLocal` at all -- it reads
Project Reactor's own `Context`, which is attached to the *subscription*, not a thread, and
which Reactor propagates through every operator in the chain regardless of which
`Scheduler` runs which step. `ReactiveSecurityContextHolder.withAuthentication(auth)` produces
a `Context` you attach with `.contextWrite(...)`. Scenarios C and D repeat A and B with this
mechanism instead, and D survives the identical `publishOn` hop that killed B:
```
C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: Hello, carol
D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: Hello, dave
```
This is the post's `getProfile()` example, reproduced verbatim as `Demo5ReactiveContext.getProfile()`:
```java
static Mono<String> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> "Hello, " + securityContext.getAuthentication().getName())
.defaultIfEmpty("Anonymous");
}
```
## Edge case: `map()` cannot emit `null`
Writing this demo's JUnit counterpart in [Chapter 8](08-testing-contract.md) hit a real
`NullPointerException` on the first attempt: `Mono.map(...)` throws if the mapper function
returns `null` (Reactor treats a `null` signal as a programming error, not an empty result --
that's what `Mono.empty()`/`defaultIfEmpty()` are for). A lambda that reads
`SecurityContextHolder.getContext().getAuthentication()` and returns it directly breaks the
moment the authentication is absent. The fix used throughout this demo and its test is to map
to a descriptive `String` ("NO AUTHENTICATION (lost)") instead of passing a possibly-null
domain object through a reactive operator. This is a real trap for exactly the kind of
diagnostic code you'd add while debugging a context-propagation bug in a reactive pipeline.
## Edge case: no context ever written
Scenario E calls `getProfile()` with no `.contextWrite(...)` anywhere upstream at all --
`ReactiveSecurityContextHolder.getContext()` completes **empty**, not with an error, so
`.defaultIfEmpty("Anonymous")` fires cleanly:
```
E) getProfile() with no contextWrite() upstream at all: Anonymous
```
This matters for the post's `/profile` endpoint: an anonymous request to a permitted path
never throws inside `getProfile()`, it degrades to "Anonymous" -- the `defaultIfEmpty` isn't
defensive boilerplate, it's covering a real, reachable case.
## What this means for `@AuthenticationPrincipal Mono<UserDetails>`
The post's second example, `getUser(@AuthenticationPrincipal Mono<UserDetails> user)`, is the
same mechanism at one more remove: Spring Security resolves that `Mono` parameter by reading
`ReactiveSecurityContextHolder` internally before your method runs, so it inherits everything
in this chapter for free. If a `Mono<UserDetails>` argument comes back empty in a WebFlux
controller for a request you expected to be authenticated, the two things worth checking first
are exactly A and B above: is a `ThreadLocal`-based mechanism (yours or a library's) trying to
read the context after a scheduler hop, and is `.contextWrite(...)` actually upstream of the
read in the chain that populates it (`SecurityWebFilterChain` normally handles this for you,
but a hand-rolled `WebFilter` that reorders operators can break it).

View File

@@ -0,0 +1,87 @@
# 6. DelegatingSecurityContextTaskScheduler and the synthetic system identity
[&larr; Prev: Reactive context](05-reactive-context.md) | [Next: Servlet filter persistence &rarr;](07-servlet-filter-persistence.md)
Every previous chapter is about carrying *somebody's* context across a thread or scheduler
boundary. `Demo6ScheduledSystemIdentity.java` is about the case where that framing breaks
down: a `@Scheduled` cron trigger has no caller, so there is no `Authentication` anywhere to
propagate in the first place. Full output in [`docs/output/demo6.txt`](output/demo6.txt).
## What `DelegatingSecurityContextTaskScheduler` actually captures
Before writing this demo, the class's bytecode was read directly (not assumed from the
Javadoc) to answer one question precisely: does the single-argument constructor capture
`SecurityContextHolder.getContext()` once, when the wrapper is built, or fresh, every time
`schedule(...)` is called? The constructor itself stores a `null` `SecurityContext` field;
`wrap(Runnable)` -- called synchronously from inside every `schedule*` method -- passes that
`null` to `DelegatingSecurityContextRunnable.create(...)`, which resolves `null` to
`SecurityContextHolder.getContext()` at that exact call site. So the capture happens **per
call to `schedule()`**, on whatever thread makes that call, not once at wrapper-construction
time. Scenario B in the demo proves it against the real class rather than the disassembly:
```
B1) first schedule() call, caller context = registration-thread-X: authenticated as registration-thread-X
B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: authenticated as registration-thread-Y
```
Two `schedule()` calls on the identical `DelegatingSecurityContextTaskScheduler` instance,
with the calling thread's `SecurityContextHolder` changed in between, capture two different
contexts independently. In a real Spring app, `ScheduledTaskRegistrar.afterPropertiesSet()`
calls `schedule()` once per `@Scheduled` method, all during application context refresh on the
startup thread -- which is almost always running with **no** `SecurityContext` at all.
Scenario A is that realistic case:
```
A) schedule() called with NO context present on the caller thread: NO AUTHENTICATION (lost)
```
## Why the post's `createSystemContext()` pattern exists
There is no "the user" to recover here, so the fix in the post's `ScheduledTasks` example
doesn't try to propagate anything -- it constructs a brand-new `SecurityContext` from scratch,
inside the `@Scheduled` method body, with a narrowly scoped synthetic principal:
```java
private SecurityContext createSystemContext() {
Authentication systemAuth = new UsernamePasswordAuthenticationToken(
"SYSTEM", null, AuthorityUtils.createAuthorityList("ROLE_SYSTEM")
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(systemAuth);
return context;
}
```
Scenario C runs exactly this pattern and confirms the result end to end:
```
C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: SYSTEM with authorities [ROLE_SYSTEM]
```
`ROLE_SYSTEM` here, not an admin role borrowed from somewhere else in the app -- the point of
minting a dedicated identity is that a bug in the scheduled task is bounded by what
`ROLE_SYSTEM` can do, not by whatever the broadest role in the system happens to be.
## Edge case: the synthetic principal isn't "anonymous" to Spring Security
It's tempting to assume a made-up `SYSTEM` principal with no credentials is somehow a special
or unauthenticated case. It isn't -- `AuthenticationTrustResolver` has no concept of "system"
at all, and treats it as a completely ordinary authenticated principal:
```
EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): false
```
That matters anywhere the app has authorization rules keyed on `isAnonymous()` or
`isRememberMe()` (an `.anonymous()` matcher in a `SecurityFilterChain`, for instance) --
`createSystemContext()`'s output will not match those rules, which is usually what you want,
but is worth confirming rather than assuming for a security-relevant identity.
## The Boot 4.1 virtual-thread footnote
`spring.threads.virtual.enabled=true` also swaps the scheduler backing `@Scheduled` for a
`SimpleAsyncTaskScheduler` running virtual threads, the same substitution
[Chapter 2](02-async-virtual-threads.md) covers for `@Async`. The "fresh thread every time, no
pooled-worker staleness" reasoning from Chapters 1&ndash;2 applies here too, but it changes
nothing about this chapter's actual point: there is still no per-invocation user identity for
any thread model to inherit, because there was never a user in the first place.

View File

@@ -0,0 +1,86 @@
# 7. SecurityContextHolderFilter vs. SecurityContextPersistenceFilter
[&larr; Prev: Scheduled tasks](06-scheduled-tasks.md) | [Next: Testing contract + edge-case index &rarr;](08-testing-contract.md)
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`](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.

View File

@@ -0,0 +1,113 @@
# 8. Testing contract + edge-case index
[&larr; Prev: Servlet filter persistence](07-servlet-filter-persistence.md)
Every chapter so far demonstrates a claim by printing it. This chapter pins the same claims as
real JUnit 5 assertions (`mvn test`, `SecurityContextPropagationContractTest`, 10 tests) and
collects, in one place, every edge case discovered while building this repository -- the
things that would not survive a copy-paste of the post's original code samples, or that the
post's prose states as fact and this repo now proves against a real run instead. Output in
[`docs/output/tests.txt`](output/tests.txt).
## Running the tests
```bash
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
mvn test
```
The surefire plugin is configured with `--enable-preview` in `pom.xml`'s `argLine`, matching
the compiler configuration -- no extra flags needed on the command line.
## What the ten tests pin
| Test | Claim it pins | Chapter |
|---|---|---|
| `rawThreadPoolExecutorLosesContext` | An unwrapped `ThreadPoolExecutor` loses `SecurityContext` | [4](04-executor-wrapping.md) |
| `delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask` | Two tasks on a reused pool worker each keep their own context | [4](04-executor-wrapping.md) |
| `completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates` | Common `ForkJoinPool` loses context; a wrapped executor keeps it | [4](04-executor-wrapping.md) |
| `delegatingSecurityContextAsyncTaskExecutorPropagates` | `DelegatingSecurityContextAsyncTaskExecutor` propagates through `ThreadPoolTaskExecutor` | [4](04-executor-wrapping.md) |
| `reactiveContextSurvivesSchedulerHop_threadLocalDoesNot` | `ThreadLocal` fails across a `publishOn` hop; Reactor `Context` survives it | [5](05-reactive-context.md) |
| `reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext` | `getProfile()` degrades to `"Anonymous"`, does not error, with no context written | [5](05-reactive-context.md) |
| `delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction` | Two `schedule()` calls on one wrapper capture two independent contexts | [6](06-scheduled-tasks.md) |
| `securityContextPersistenceFilterAutoSaves_holderFilterDoesNot` | The Security-6 filter swap changed save behavior, not load behavior | [7](07-servlet-filter-persistence.md) |
| `securityContextHolderFilterLoadsAnExistingSession` | `SecurityContextHolderFilter` still loads correctly from a prior save | [7](07-servlet-filter-persistence.md) |
| `testSecurityContextHolderIsTheSameHolderTestSecurityContextHolderWrites` | `TestSecurityContextHolder` and `SecurityContextHolder` share one underlying holder | this chapter |
The last test reproduces the post's own "Testing Security Context Propagation" section --
specifically `AsyncServiceTest.testAsyncWithManualContext` -- almost line for line:
`TestSecurityContextHolder.setAuthentication(...)` sets the context the same way manual
`SecurityContextHolder.getContext().setAuthentication(...)` does in the post's example, then a
`DelegatingSecurityContextExecutorService`-wrapped task reads it back on a different thread and
the assertion checks the result contains the test principal's name. The point worth knowing:
`TestSecurityContextHolder` is not a separate mock holder that needs wiring -- Spring
Security's default `SecurityContextHolderStrategy` is one strategy per JVM (per thread, under
`MODE_THREADLOCAL`), and `TestSecurityContextHolder` writes through the same one production
code reads from. `@WithMockUser` is a thin annotation-driven wrapper around the same
mechanism, wired in by `WithSecurityContextTestExecutionListener` when tests run under a full
Spring `TestContext`; this repo's tests use `TestSecurityContextHolder` directly instead, since
none of the other demos need a Spring `ApplicationContext` and adding one just for this test
would be the only place in the repository that did.
## Edge-case index
Every edge case this repository actually reproduces, in one list. Each links to the chapter
that runs it.
- **A reused pool worker keeps two different tasks' contexts separate under the `Delegating*`
wrappers**, unlike plain `InheritableThreadLocal`, which leaks the previous task's value onto
a reused worker. See [Chapter 1](01-inheritable-threadlocal.md) for the leak, [Chapter
4](04-executor-wrapping.md#the-edge-case-chapter-1-sets-up-and-this-chapter-resolves) for the
fix proven independent-per-task.
- **`CompletableFuture.supplyAsync(supplier)` with no executor argument silently uses the
common `ForkJoinPool`**, which never propagates `SecurityContext` -- easy to miss because the
one-argument overload compiles fine and works in every way except this one. See [Chapter
4](04-executor-wrapping.md).
- **`MODE_INHERITABLETHREADLOCAL` is JVM-wide**: turning it on to fix one virtual-thread
executor also changes behavior for every other platform thread pool in the same process
(JDBC housekeeping threads, a hand-rolled `ThreadPoolExecutor`, the common `ForkJoinPool`
behind parallel streams) and leaks the active `SecurityContext` into background threads that
were never meant to run as the current user. See [Chapter 2](02-async-virtual-threads.md).
- **`StructuredTaskScope` inherits `ScopedValue` bindings by specification and says nothing
about `ThreadLocal`** -- `SecurityContextHolder` gets nothing for free from a `fork()` call,
which is easy to assume otherwise since the forked subtask is a fresh virtual thread, exactly
the shape that makes `MODE_INHERITABLETHREADLOCAL` work elsewhere. See [Chapter
3](03-structured-concurrency.md).
- **There is no `DelegatingSecurityContextStructuredTaskScope` and there will not be one**,
structurally: `StructuredTaskScope` does not implement `Executor`, so there is no
`execute(Runnable)` seam for a `Delegating*` class to wrap. See [Chapter
3](03-structured-concurrency.md).
- **Reactor's `Mono.map()` throws `NullPointerException` if the mapper returns `null`** --
discovered writing this repo's own JUnit test for reactive context propagation. Diagnostic
code that maps straight to `SecurityContextHolder.getContext().getAuthentication()` (which can
legitimately be `null`) breaks on the first request with no authentication, unless the mapper
returns a sentinel value or wraps in `Optional` instead. See [Chapter
5](05-reactive-context.md#edge-case-map-cannot-emit-null).
- **`ReactiveSecurityContextHolder.getContext()` completes empty, it does not error, when no
context was ever written upstream** -- `.defaultIfEmpty(...)` is covering a real, reachable
case (an anonymous request to a permitted endpoint), not defensive boilerplate. See [Chapter
5](05-reactive-context.md).
- **A `ThreadLocal` write survives inside one operator but not across a `publishOn` hop to a
different `Scheduler`** -- proven as a same-chain before/after comparison, not two unrelated
claims. See [Chapter 5](05-reactive-context.md).
- **`DelegatingSecurityContextTaskScheduler`'s single-argument constructor captures
`SecurityContextHolder.getContext()` fresh on every `schedule()` call**, not once when the
wrapper object is built -- confirmed by reading the class's bytecode before writing the demo,
then proving it against the real class. Two `schedule()` calls on the same wrapper, with the
caller's context changed in between, do not see each other's value. See [Chapter
6](06-scheduled-tasks.md).
- **A synthetic `SYSTEM` principal is not "anonymous" to `AuthenticationTrustResolver`** --
authorization rules keyed on `isAnonymous()` will not match it, which matters for anything
gated by `.anonymous()` in a `SecurityFilterChain`. See [Chapter 6](06-scheduled-tasks.md).
- **`SecurityContextHolderFilter` (Security 6+ default) only ever *loads* -- it has no code
path that calls `SecurityContextRepository.saveContext(...)` at all**, confirmed by running
it against a real session and observing nothing gets written, then confirming the load side
still works correctly against a session seeded by an earlier explicit save. See [Chapter
7](07-servlet-filter-persistence.md).
- **`SecurityContextHolder.getContext()` never returns `null`** -- an unauthenticated request
gets an empty `SecurityContext` object whose `getAuthentication()` is `null`, not a `null`
context. Code that checks `context == null` to detect "nobody's authenticated" is checking
the wrong condition. See [Chapter 7](07-servlet-filter-persistence.md).
- **`TestSecurityContextHolder` and production `SecurityContextHolder` read and write the
same underlying strategy** -- there's no separate mock state to keep in sync. See this
chapter, above.

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

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-2]
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]

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

View File

@@ -0,0 +1,9 @@
=== Demo 4: Executor/ExecutorService/AsyncTaskExecutor wrapping on a pooled platform thread ===
A) raw ThreadPoolExecutor, no wrapper: NO AUTHENTICATION (lost)
B) DelegatingSecurityContextExecutorService.execute(...): authenticated as bob
B2) DelegatingSecurityContextExecutorService.submit(Callable): authenticated as bob
EDGE) task 1 on possibly-reused worker: authenticated as carol-task1
EDGE) task 2, same pool, different caller context: authenticated as dave-task2 <-- correct, NOT stale, unlike plain InheritableThreadLocal on a reused worker
C) DelegatingSecurityContextExecutor + CompletableFuture.supplyAsync: authenticated as erin
C2) default CompletableFuture executor (common ForkJoinPool), no wrapper: NO AUTHENTICATION (lost)
D) DelegatingSecurityContextAsyncTaskExecutor wrapping ThreadPoolTaskExecutor: authenticated as grace

View File

@@ -0,0 +1,6 @@
=== Demo 5: ReactiveSecurityContextHolder vs. ThreadLocal across a scheduler hop ===
A) SecurityContextHolder (ThreadLocal), no scheduler hop: authenticated as alice
B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: NO AUTHENTICATION (lost) [proves ThreadLocal doesn't survive a scheduler hop]
C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: Hello, carol
D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: Hello, dave [Context travels with the stream, not the thread]
E) getProfile() with no contextWrite() upstream at all: Anonymous [defaultIfEmpty fires; getContext() completes empty, it does not error]

View File

@@ -0,0 +1,6 @@
=== Demo 6: DelegatingSecurityContextTaskScheduler and the synthetic system identity ===
A) schedule() called with NO context present on the caller thread: NO AUTHENTICATION (lost) [this is the realistic startup case the post warns about]
B1) first schedule() call, caller context = registration-thread-X: authenticated as registration-thread-X
B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: authenticated as registration-thread-Y [independent per-call capture, not frozen at wrapper construction]
C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: SYSTEM with authorities [ROLE_SYSTEM]
EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): false [false -- SYSTEM is a normal authenticated principal, not Spring Security's anonymous concept]

View File

@@ -0,0 +1,6 @@
=== Demo 7: SecurityContextHolderFilter vs SecurityContextPersistenceFilter -- load vs. load+save ===
A) SecurityContextPersistenceFilter, context set mid-chain, auto-saved to session after chain returns: true [true -- this filter saves for you]
B) SecurityContextHolderFilter, context set mid-chain, auto-saved to session after chain returns: false [false -- requireExplicitSave's default; nothing persists unless you save it yourself]
C) SecurityContextHolderFilter + explicit repository.saveContext(...) inside the chain: true [true -- the workaround the post recommends actually works]
D) SecurityContextHolderFilter, context already saved in an existing session, next request: authenticated as dave [it does load -- "only loads, never saves" describes the SAVE side, not the LOAD side]
EDGE) brand-new request, no prior session, nothing set: NO AUTHENTICATION (empty context, not an error)

View File

@@ -0,0 +1,6 @@
mvn test -- SecurityContextPropagationContractTest (10 tests pinning the claims each demo prints above)
-------------------------------------------------------------------------------
Test set: com.ankurm.vt.SecurityContextPropagationContractTest
-------------------------------------------------------------------------------
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.413 s -- in com.ankurm.vt.SecurityContextPropagationContractTest