You must call a blocking library that reads SecurityContextHolder (ThreadLocal) from within a WebFlux request. How do you bridge the reactive SecurityContext to that ThreadLocal correctly, and what are the risks?
answer
- Micrometer context-propagation + ThreadLocalAccessor
- Hooks.enableAutomaticContextPropagation() / contextCapture()
- ContextRegistry / ContextSnapshot
- offload to Schedulers.boundedElastic()
- clear ThreadLocals or leak principal across requests
basics
~10 sUse the Micrometer context-propagation library to copy the reactive SecurityContext into a ThreadLocal for the blocking call. Enable Reactor's automatic context propagation (Hooks.enableAutomaticContextPropagation) or contextCapture(), and run the blocking code on Schedulers.boundedElastic().
solid answer
~40 sThe blocking library reads SecurityContextHolder's ThreadLocal, which is empty on the event loop. The bridge is Micrometer's context-propagation library plus a registered ThreadLocalAccessor for the security context. With Reactor 3.5+ you enable Hooks.enableAutomaticContextPropagation() (Boot 3.x wires the accessors), so when Reactor restores threads it copies known keys from the Reactor Context into the corresponding ThreadLocals around each operator; alternatively use contextCapture() to snapshot and restore explicitly. You still offload the blocking call to Schedulers.boundedElastic() via subscribeOn/publishOn so you never block the event loop. Risks: ThreadLocals must be set and cleared around every restored segment or you leak one request's principal into another (a serious security bug); automatic propagation adds per-operator overhead; and it only works for keys with a registered accessor. Prefer avoiding the blocking dependency; bridge only when unavoidable.
code
java · 23 lines@Configuration
class PropagationConfig {
@PostConstruct
void enable() {
// Boot 3.x often does this for you; shown explicitly for clarity
Hooks.enableAutomaticContextPropagation();
}
}
@Service
class ReportService {
private final LegacyBlockingService legacy; // internally reads SecurityContextHolder
ReportService(LegacyBlockingService legacy) { this.legacy = legacy; }
Mono<Report> currentUserReport() {
return Mono.fromCallable(legacy::buildForCurrentUser) // reads ThreadLocal
.subscribeOn(Schedulers.boundedElastic()); // never block the event loop
// With automatic propagation + Spring Security's ThreadLocalAccessor,
// SecurityContextHolder is populated on the boundedElastic thread
// and cleared afterward, preventing cross-request leakage.
}
}go deeper
Not expected — just know blocking libs that read the ThreadLocal don't work directly on WebFlux.
Know the fix exists (Micrometer context-propagation) and that blocking must go on boundedElastic.
Explain ThreadLocalAccessor / automatic propagation vs contextCapture and the offloading requirement.
Weigh the security-leak risk of unclosed ThreadLocals, the perf cost of automatic propagation, and argue for avoiding the blocking dependency in the first place.
## The situation You're on WebFlux (event loop, Reactor Context holds the `SecurityContext`), but you must invoke **blocking code that reads `SecurityContextHolder.getContext()`** — an older Spring Security integration, an audit library, a `@PreAuthorize` on an imperative bean, etc. That code reads a **ThreadLocal** which, on the event-loop thread, is empty. Two problems must be solved together: (1) don't block the event loop; (2) make the ThreadLocal contain the right principal for the duration of the blocking call. ## The bridge: Micrometer context-propagation Since Reactor 3.5 / Spring Boot 3.x, the mechanism is the **`io.micrometer:context-propagation`** library. It defines: - **`ThreadLocalAccessor`** — knows how to read/write one ThreadLocal (e.g. Spring Security registers one that maps the reactive `SecurityContext` key to `SecurityContextHolder`). - **`ContextRegistry`** — the registry of accessors. - **`ContextSnapshot`** — captures current context values. Reactor integrates with it in two ways: 1. **Automatic propagation:** `Hooks.enableAutomaticContextPropagation()` (Boot typically enables this). Reactor then, on every thread restore, copies values from the subscription's Reactor Context into the matching ThreadLocals (via registered accessors) *around the operator*, and clears them afterward. So blocking code that reads `SecurityContextHolder` on a `boundedElastic` thread sees the right principal. 2. **Explicit capture/restore:** `contextCapture()` operator, or manually `ContextSnapshot.captureAll()` and `snapshot.wrap(runnable)` / try-with-resources `setThreadLocals()` when you hand work to a non-Reactor executor. ## Always offload blocking work Regardless of propagation, wrap the blocking call so it never runs on the event loop: ```java Mono.fromCallable(() -> legacyAuditService.record()) // reads SecurityContextHolder .subscribeOn(Schedulers.boundedElastic()); ``` `boundedElastic` is Reactor's scheduler for blocking I/O. ## Putting it together ```java // Boot 3.x: automatic propagation typically on. Spring Security registers the accessor. Mono<Report> report = ReactiveSecurityContextHolder.getContext() .flatMap(ctx -> Mono.fromCallable(() -> { // On a boundedElastic thread; SecurityContextHolder is populated by propagation return legacyBlockingService.buildReportForCurrentUser(); }).subscribeOn(Schedulers.boundedElastic())); ``` ## Risks and trade-offs 1. **Leakage / stale ThreadLocals (security bug):** if a ThreadLocal is set but not cleared after the operator/segment, the next task on that pooled thread inherits a stale principal — one user acting as another. Automatic propagation handles clearing, but hand-rolled executor bridging must clear in a `finally` (use `ContextSnapshot`'s try-with-resources). This is the highest-severity risk. 2. **Performance:** automatic context propagation adds work on every thread-restore boundary; on hot paths it's measurable. 3. **Only registered keys propagate:** if there's no `ThreadLocalAccessor` for a value, it won't appear in the ThreadLocal. You must ensure the security accessor is registered (Boot's `spring-security` reactive support does this). 4. **Blocking is still blocking:** even bridged correctly, you've reintroduced a blocking dependency; capacity is bounded by `boundedElastic`. Prefer a reactive equivalent of the library if one exists. ## Design guidance - **First choice:** avoid the blocking library; use a reactive-native path so the principal stays in the Reactor Context and no ThreadLocal is needed. - **If unavoidable:** enable automatic propagation, offload to `boundedElastic`, and rely on registered accessors; never manually set ThreadLocals without guaranteed cleanup. - **Legacy note:** before Micrometer context-propagation, teams used `Schedulers.onScheduleHook`/`Operators.liftPublisher` or Reactor's older `ReactorContextTestExecutionListener` tricks — mention only as history; the current answer is Micrometer context-propagation. This question separates architects who understand the reactive/imperative boundary (and its security-leak hazards) from those who only know the happy-path reactive API.
- What is the single most dangerous failure mode when bridging the reactive context into a ThreadLocal, and how is it avoided?Failing to clear the ThreadLocal after the blocking segment, so a pooled thread carries a stale SecurityContext into the next request — one user impersonating another. It's avoided by using Micrometer context-propagation's automatic restore/clear (or ContextSnapshot try-with-resources) that guarantees teardown, never a bare ThreadLocal.set().
- Why isn't enabling automatic context propagation free?It hooks every thread-restore boundary to snapshot the Reactor Context and set/clear the registered ThreadLocals around operators, adding per-operator overhead. On hot reactive paths that cost is measurable, so it's a deliberate trade-off — ideally you avoid the blocking dependency entirely.
saying these in an interview costs you the question
- Manually calling SecurityContextHolder.setContext(...) on an executor thread without clearing it (leaks the principal to the next task).
- Claiming the Reactor Context automatically populates SecurityContextHolder with no library or accessor.
- Running the blocking library directly on the event loop instead of boundedElastic.
- Believing context-propagation works for arbitrary values without a registered ThreadLocalAccessor.