skip to content

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?

level: seniorimportance: should knowfreq 40%

answer

  1. one returned chain = context alive
  2. nested subscribe()/block() = fresh empty context
  3. orphaned Mono has no context
  4. publishOn/subscribeOn keep context (thread hop is fine)
  5. read once, pass the value down

basics

~20 s

The 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 s

The 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
java
// 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

for a junior

Know the rule of thumb: keep everything in one returned reactive chain; don't call subscribe()/block() inside.

for a middle

Identify nested subscribe/block and orphaned publishers as context-loss causes and the read-once-pass-down fix.

for a senior

Distinguish thread-hopping (safe) from breaking the subscription (unsafe), and know the boundary with blocking libs.

for a principal

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.

context