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.
4.9 KiB
5. ReactiveSecurityContextHolder and Reactor Context
← Prev: Executor/ExecutorService wrapping | Next: Scheduled tasks →
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.
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():
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 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).