1
0
Files
spring-security-demo/context-propagation/docs/05-reactive-context.md
asmhatre 5e9e7f1b12 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
2026-08-25 02:01:29 +00:00

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).