A developer reads the principal fine at the top of a WebFlux handler but gets an empty Mono deeper in the pipeline. What common mistakes break Reactor Context propagation of the security context?
answer
- one returned chain = context alive
- nested subscribe()/block() = fresh empty context
- orphaned Mono has no context
- publishOn/subscribeOn keep context (thread hop is fine)
- read once, pass the value down
basics
~20 sThe context is lost whenever you leave the connected chain: subscribing to a separate publisher, using an independent subscribe()/block, spawning work whose context you don't propagate, or mixing in blocking code that reads SecurityContextHolder. Keep everything in one returned chain.
solid answer
~40 sThe Reactor Context only exists inside a single, connected subscription that you return from the handler. It's lost when you break that connection: calling subscribe() or block() on an inner publisher (starting a fresh subscription with an empty context), building a new Mono/Flux that isn't composed into the returned chain, dispatching work to a plain Executor/CompletableFuture/@Async without carrying the context, or calling into blocking libraries that read the ThreadLocal SecurityContextHolder (empty on the event loop). Fixes: compose everything with flatMap/map/zipWith into the one returned pipeline; read the principal once via ReactiveSecurityContextHolder.getContext() and pass the value down as a parameter; for legitimate offloading use publishOn/subscribeOn (which preserve the subscription's context) rather than manual threads. Note that publishOn/subscribeOn change threads but keep the Reactor Context — thread hopping is fine; breaking the subscription is not.
code
java · 16 lines// BAD: nested subscribe -> empty context deep in the pipeline
Mono<Void> broken() {
return service.load().doOnNext(x ->
ReactiveSecurityContextHolder.getContext() // NEW subscription
.subscribe(ctx -> audit(ctx.getAuthentication().getName()))) // empty!
.then();
}
// GOOD: read once, thread the value through one connected chain
Mono<Void> fixed() {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication().getName())
.flatMap(username -> service.load()
.doOnNext(x -> audit(username)) // plain value, always present
.then());
}go deeper
Know the rule of thumb: keep everything in one returned reactive chain; don't call subscribe()/block() inside.
Identify nested subscribe/block and orphaned publishers as context-loss causes and the read-once-pass-down fix.
Distinguish thread-hopping (safe) from breaking the subscription (unsafe), and know the boundary with blocking libs.
Reason about correct offloading strategies, Micrometer context-propagation bridging, and diagnosing config-vs-propagation causes of empty context.
## Core rule The security context lives in the **Reactor Context of one subscription** — the chain you `return` from your `@Controller`/`@RestController` method. As long as your operators are composed into that single chain, the context is visible everywhere, even across thread hops. You lose it the instant you step outside that chain. ## Ways developers break propagation ### 1. Independent subscription (subscribe/block on an inner publisher) ```java // BAD: inner subscribe starts a brand-new subscription with EMPTY context return Mono.fromRunnable(() -> { ReactiveSecurityContextHolder.getContext() .subscribe(ctx -> log.info(ctx.getAuthentication().getName())); // empty! }); ``` A nested `subscribe()` (or `block()`) creates a new subscription that does **not** inherit the outer chain's context. `getContext()` there returns empty. ### 2. Not composing into the returned chain ```java // BAD: this Mono is created but never chained into the returned value Mono<String> name = ReactiveSecurityContextHolder.getContext() .map(c -> c.getAuthentication().getName()); return repository.findAll(); // 'name' is orphaned; its context was never wired ``` Only the returned pipeline carries the request's context. Orphaned publishers have no context. ### 3. Handing work to raw threads / CompletableFuture / @Async Dispatching to a plain `ExecutorService`, `CompletableFuture.supplyAsync`, or a Servlet-style `@Async` method leaves the Reactor Context behind — those aren't part of the subscription, and by default no ThreadLocal is populated either. ### 4. Calling blocking code that reads SecurityContextHolder A legacy library deep in the call path may call `SecurityContextHolder.getContext()`. On the event loop that ThreadLocal is empty. This needs the Micrometer **context-propagation** library (`Hooks.enableAutomaticContextPropagation()` / `contextCapture()`) to copy the reactive context into a ThreadLocal for the blocking call — a separate topic, but the symptom shows up here. ## What does NOT break it - **`publishOn` / `subscribeOn`**: they change the executing thread but keep the same subscription and its Reactor Context. Thread hopping is safe. - **`flatMap` / `concatMap` / `zipWith` / `transform`**: inner publishers created by these operators are subscribed *within* the outer subscription, so they inherit the context. ## Practical fixes 1. **Read once, pass down.** Resolve the principal at the top and thread the value through: ```java return ReactiveSecurityContextHolder.getContext() .map(c -> c.getAuthentication().getName()) .flatMap(username -> service.doWork(username)); // username is a plain value now ``` 2. **Keep one chain.** Compose every branch with reactive operators; never `subscribe()`/`block()` in the middle. 3. **Offload correctly.** For CPU work use `publishOn(Schedulers.parallel())`; for blocking I/O wrap in `Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())` — both preserve the context. 4. **Bridge to blocking libs deliberately** using Micrometer context-propagation if you truly must call ThreadLocal-based code. ## Debugging tip If `getContext()` is empty only deep in the chain, look for a stray `subscribe()`/`block()`, an orphaned publisher, or a jump onto a non-Reactor thread (manual executor). If it's empty even at the top, the filter chain didn't authenticate (wrong `ServerHttpSecurity` config) — a different, sibling-owned problem.
- Does using publishOn(Schedulers.boundedElastic()) lose the security context?No. publishOn/subscribeOn change the executing thread but remain within the same subscription, so the Reactor Context (and thus the security context) is preserved. What loses it is breaking the subscription — a nested subscribe()/block() or handing work to a non-Reactor executor.
- Your handler's getContext() is empty even at the very first operator. Reactor propagation looks correct. What now?The problem is upstream: the security filter chain never authenticated the request (misconfigured ServerHttpSecurity, missing/invalid credentials, or a repository that didn't persist the context). That's a reactive Spring Security config issue, distinct from context-propagation mechanics.
saying these in an interview costs you the question
- Saying publishOn/subscribeOn lose the security context (they don't; thread hopping is safe within one subscription).
- Recommending .block() to 'grab the principal synchronously' inside a handler.
- Believing a manually created CompletableFuture inherits the Reactor Context automatically.
- Assuming a nested subscribe() shares the outer chain's context.