Spring Security’s SecurityContext is the cornerstone of authentication and authorization in Spring applications. It stores critical user details like authentication token, granted authorities, and principal information. However, when operations span multiple threads—common in asynchronous processing or reactive programming—propagating this context becomes a significant challenge. This guide explores various strategies to ensure your security context travels correctly across thread boundaries.
Updated 2026-08-24 with three new sections most guides skip: what changes under virtual threads on Spring Boot 4.1, why DelegatingSecurityContextExecutor still matters once you have them, and how structured concurrency (StructuredTaskScope) handles — or doesn’t handle — the security context at all. Every new example was run for real, not paraphrased from docs; output captured, not retyped, in the companion repo, spring-security-demo.
| Component | Version |
|---|---|
| JDK | 25 (Temurin 25.0.4.1+1), LTS, GA 2025-09-16 |
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| Spring Security | 7.1.1 (GA 2026-06-09) |
io.micrometer:context-propagation |
1.2.1 (as managed by Boot 4.1.1’s BOM) |
StructuredTaskScope is a preview API on JDK 25 (JEP 505) and remains preview through JDK 26 (JEP 525) — every example that uses it needs --enable-preview to compile and run.
Understanding SecurityContext and ThreadLocal
At the heart of Spring Security lies the SecurityContextHolder, which uses a ThreadLocal strategy by default. This means each thread maintains its own isolated security context, which works perfectly in standard synchronous request-response cycles but breaks down when new threads are spawned.
The SecurityContextHolder supports three persistence strategies:
MODE_THREADLOCAL: Default strategy where context is bound to the current threadMODE_INHERITABLETHREADLOCAL: Context is inherited by child threads created by the current threadMODE_GLOBAL: Single context shared across all threads (rarely used in production)
You can configure the strategy programmatically:
@SpringBootApplication
public class SecurityApplication {
public static void main(String[] args) {
// Set strategy before Spring Boot starts
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
);
SpringApplication.run(SecurityApplication.class, args);
}
}
However, MODE_INHERITABLETHREADLOCAL has limitations with managed thread pools and doesn’t work with reactive streams or CompletableFuture chains.
Security Context Propagation in Servlet Environment
In traditional servlet-based applications, Spring Security handles context propagation through a filter that runs before every request. Since Spring Security 6.0 that filter is SecurityContextHolderFilter, not SecurityContextPersistenceFilter — the latter is still present in 7.1.1 but deprecated. The two behave differently in a way that matters: SecurityContextPersistenceFilter loaded the context at the start of the request and automatically saved it back at the end. SecurityContextHolderFilter only loads — it never saves anything. If your code sets SecurityContextHolder.setContext(...) and expects it to survive to the next request, you have to save it yourself, via SecurityContextRepository.saveContext(...).
The configuration is typically automatic, but you can customize it:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(withDefaults())
// SecurityContextHolderFilter is automatically added
.securityContext(securityContext -> securityContext
.requireExplicitSave(true) // the default since Security 6.0 -- shown here for clarity
);
return http.build();
}
}
requireExplicitSave(true) is not an opt-in feature to turn on — it has been the default since Security 6.0, and the call above is only there to make the behavior visible in the example. It exists because the old auto-save behavior wrote to the HttpSession on every request whether or not the context had changed, which is wasteful, and made it ambiguous whether a given write was intentional. In practice this rarely bites you: the framework’s own authentication filters (form login, basic auth, OAuth2 login) already call securityContextRepository.saveContext(...) for you after a successful login. It only becomes your problem in code that sets SecurityContextHolder.setContext(...) directly outside of that flow — a custom pre-authentication filter, for instance — where the context will silently vanish on the next request unless you save it explicitly.
Security Context Propagation in Reactive WebFlux
Reactive applications using Spring WebFlux operate differently. They use a non-blocking event loop model where a single thread can handle multiple requests. Here, ThreadLocal doesn’t work because operations jump between threads.
Instead, Spring Security provides ReactiveSecurityContextHolder which integrates with Project Reactor’s Context. The security context travels with the reactive stream, not the thread.
@RestController
public class ReactiveController {
@GetMapping("/profile")
public Mono<String> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> {
Authentication auth = securityContext.getAuthentication();
return "Hello, " + auth.getName();
})
.defaultIfEmpty("Anonymous");
}
// Alternatively, use @AuthenticationPrincipal
@GetMapping("/user")
public Mono<String> getUser(@AuthenticationPrincipal Mono<UserDetails> user) {
return user.map(u -> "User: " + u.getUsername())
.defaultIfEmpty("No user");
}
}
For WebFlux security configuration, use SecurityWebFilterChain:
@Configuration
@EnableWebFluxSecurity
public class ReactiveSecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(
ServerHttpSecurity http) {
return http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/public/**").permitAll()
.anyExchange().authenticated()
)
.httpBasic(withDefaults())
.formLogin(withDefaults())
.build();
}
}
Security Context in Asynchronous Processing
Asynchronous methods pose unique challenges because they execute on different threads from the thread pool. Spring Security provides several solutions depending on your async mechanism.
Using @Async
When using @Async, the security context is lost because the method runs on a thread from Spring’s task executor. The solution is to wrap the executor with DelegatingSecurityContextAsyncTaskExecutor:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("Async-");
executor.initialize();
// Wrap executor to propagate security context
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
}
@Service
public class AsyncService {
@Async
public CompletableFuture<String> processSensitiveData() {
// Security context is now available
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return CompletableFuture.completedFuture("Processed by: " + auth.getName());
}
}
Using ExecutorService
For manual thread pool management, wrap your tasks with DelegatingSecurityContextRunnable or DelegatingSecurityContextCallable:
@Service
public class TaskExecutionService {
private final ExecutorService executorService;
public TaskExecutionService() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()
);
// Wrap entire executor service
this.executorService = new DelegatingSecurityContextExecutorService(executor);
}
public void executeTask() {
// Context automatically propagated
executorService.execute(() -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("Executing as: " + auth.getName());
});
}
}
Using CompletableFuture
CompletableFuture uses the common ForkJoinPool by default, which doesn’t propagate context. Supply a custom executor:
@Service
public class CompletableFutureService {
private final Executor executor;
public CompletableFutureService() {
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()
);
this.executor = new DelegatingSecurityContextExecutor(threadPool);
}
public CompletableFuture<String> asyncOperation() {
return CompletableFuture.supplyAsync(() -> {
// Security context is preserved
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "Operation completed by: " + auth.getName();
}, executor);
}
}
Virtual Threads Change the @Async Picture
Everything above predates one thing: Spring Boot 4.1 lets you flip spring.threads.virtual.enabled=true and the @Async executor stops being a ThreadPoolTaskExecutor with a fixed worker pool. It becomes a SimpleAsyncTaskExecutor that starts a brand-new virtual thread per task — the same bean backs MVC async request handling and WebFlux’s blocking-execution support. That single change quietly invalidates a warning this post used to repeat without qualification.
Why MODE_INHERITABLETHREADLOCAL stopped being dangerous
The standard advice against SecurityContextHolder.MODE_INHERITABLETHREADLOCAL is really advice about pooled threads: a ThreadPoolTaskExecutor worker is constructed once and reused for every task after that, so it only ever inherits whatever was set at pool-creation time, not at submission time. A virtual thread from SimpleAsyncTaskExecutor.setVirtualThreads(true) is never reused — a fresh one is constructed per task, and InheritableThreadLocal copies its value at construction. I checked this directly rather than trust that reasoning:
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("vt-");
executor.setVirtualThreads(true); // what spring.threads.virtual.enabled=true wires up
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); // default
executor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println(a); // null -- lost
});
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
executor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println(a); // present -- the virtual thread was fresh, so inheritance worked
});
Real output from that exact code (full listing in the companion repo’s Demo2AsyncVirtualThreads.java):
B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): authenticated as bob
This is the exact symptom behind a real Spring Security issue: someone wired a raw Executors.newVirtualThreadPerTaskExecutor() as the async executor and got AccessDeniedException from inside the async method, with no obvious cause. Scenario A above reproduces it on purpose. MODE_INHERITABLETHREADLOCAL is still a JVM-wide setting, though — it affects every thread your app creates, not just this executor. Weigh that against the two more targeted fixes below.
Two qualifications worth being precise about, because it is easy to overstate what scenario B actually proves. First, it only holds for threads that are genuinely fresh and never reused — that is true of SimpleAsyncTaskExecutor‘s virtual threads, but most real apps still have platform thread pools elsewhere (a JDBC connection pool’s housekeeping thread, a hand-rolled ThreadPoolExecutor, the common ForkJoinPool behind parallel streams), and those still carry the original staleness risk this mode has always had. Flipping MODE_INHERITABLETHREADLOCAL on doesn’t audit your app for you. Second, “safe” here means “propagates correctly,” not “propagates only where you want it to” — every new platform Thread anywhere in the process, including ones that have nothing to do with handling the current request, now inherits whatever SecurityContext was active when they were constructed. For a background thread that is supposed to run as the system rather than the current user, that is a real information-leak shape, not just a performance footnote.
DelegatingSecurityContextExecutor still works, unconditionally
Nothing about DelegatingSecurityContextExecutor changed, because it never relied on thread-local inheritance in the first place — it wraps your Runnable and explicitly calls SecurityContextHolder.setContext(...)/clearContext() around the delegate’s run(), on whatever thread that turns out to be. That is why it is still the right default for library code that can’t assume the application has set a global strategy:
SimpleAsyncTaskExecutor virtualExecutor = new SimpleAsyncTaskExecutor("vt-");
virtualExecutor.setVirtualThreads(true);
Executor executor = new DelegatingSecurityContextExecutor(virtualExecutor);
executor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println(a); // authenticated as carol -- works regardless of strategy mode
});
The mechanism that’s actually new: ContextPropagatingTaskDecorator
This is the one piece of the picture that genuinely did not exist in a usable form when this guide first went up. Spring Security 6.5 (GA 2025-05-19) added SecurityContextHolderThreadLocalAccessor, which self-registers with Micrometer’s ContextRegistry through ServiceLoader the moment io.micrometer:context-propagation is on the classpath — no bean, no configuration required. Spring Framework’s ContextPropagatingTaskDecorator (since 6.1) uses that registry to snapshot and restore every registered thread-local around a task. Set it on the executor and @Async methods get the SecurityContext back without any DelegatingSecurityContext* wrapper — and MDC and tracing context come along for free, which the Delegating* classes never touched:
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("vt-");
executor.setVirtualThreads(true);
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
executor.execute(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
System.out.println(a); // authenticated as dave -- no Delegating* wrapper anywhere
});
io.micrometer:context-propagation is already on the classpath of any Boot 4.1 app pulling in micrometer-observation — actuator, tracing, or a spring-boot-starter-micrometer-* starter. If your app has no Micrometer dependency anywhere, add io.micrometer:context-propagation:1.2.1 explicitly (the version Boot 4.1.1’s BOM manages). One caveat worth flagging honestly: Spring Security’s own Concurrency Support reference page still documents only the Delegating* family as of 7.1.1 — this pattern is real and shipped, the reference docs just haven’t caught up to it yet. Full runnable listing: docs/02-async-virtual-threads.md in the companion repo.
Security Context in Structured Concurrency
StructuredTaskScope (JEP 505) is a preview API through JDK 25 and remains preview in JDK 26 (JEP 525, sixth preview) — every example below needs --enable-preview to compile and run. Worth checking before you reach for it in production code, but the propagation question is worth settling now because the failure mode is easy to miss: a fork() call starts a fresh virtual thread for the subtask, same as the executors above, so you might assume the same inheritance story applies. It only half does.
JEP 525’s own text is explicit about one kind of context and silent about another: “Subtasks forked in a scope inherit ScopedValue bindings.” That is a specified guarantee for ScopedValue — and SecurityContextHolder is a ThreadLocal, not a ScopedValue. Nothing in the structured concurrency API changes how ThreadLocal behaves, and running it confirms the gap directly:
try (var scope = StructuredTaskScope.open()) {
Subtask<String> s = scope.fork(() -> {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
return String.valueOf(a); // null with the default MODE_THREADLOCAL strategy
});
scope.join();
System.out.println(s.get());
}
There is no DelegatingSecurityContextStructuredTaskScope and there will not be one, for a structural reason: StructuredTaskScope does not implement Executor or ExecutorService. Every class in the Delegating* family works by wrapping an Executor‘s execute(Runnable) method — there is no such seam here, because fork() takes the Callable directly and starts the thread itself. So the two patterns below are not alternatives to a wrapper class that happens not to exist yet; they are what a Delegating* wrapper does internally (push the context, run the delegate, pop the context), written out explicitly because there is nothing to wrap. Capture the SecurityContext before opening the scope and restore it manually inside each forked task:
SecurityContext captured = SecurityContextHolder.getContext();
try (var scope = StructuredTaskScope.open()) {
Callable<String> task = () -> {
SecurityContextHolder.setContext(captured);
try {
return doSecuredWork();
} finally {
SecurityContextHolder.clearContext();
}
};
Subtask<String> s = scope.fork(task);
scope.join();
System.out.println(s.get()); // authenticated as grace
}
Or reach for the same Micrometer mechanism as the async section, this time wrapping the forked Callable instead of decorating an executor — worth it once you have more than the security context to carry across the scope boundary:
ContextSnapshot snapshot = ContextSnapshotFactory.builder().build().captureAll();
try (var scope = StructuredTaskScope.open()) {
Callable<String> task = snapshot.wrap(() -> doSecuredWork());
Subtask<String> s = scope.fork(task);
scope.join();
System.out.println(s.get()); // authenticated as heidi
}
All four scenarios, run for real (full listing: Demo3StructuredConcurrency.java):
B) plain fork, MODE_INHERITABLETHREADLOCAL: authenticated as frank
C) manual capture/restore: authenticated as grace
D) ContextSnapshot.wrap: authenticated as heidi
(Scenario B, plain fork() under MODE_INHERITABLETHREADLOCAL, also propagates — for the same reason it does in the @Async case: the subtask thread is fresh, not pooled. It carries the same JVM-wide caveat as before, which is why C and D are the patterns worth defaulting to in code you don’t want tied to a global setting.) StructuredTaskScope gives SecurityContextHolder nothing for free. If a forked subtask needs to call a secured service, wrap it explicitly.
Security Context in Scheduled Tasks
Every propagation pattern so far has been about carrying somebody’s context across a thread boundary. Scheduled tasks break that framing, because there is no somebody — a cron trigger is not a request, and there is no caller sitting on a thread with an Authentication to propagate. DelegatingSecurityContextTaskScheduler can only propagate whatever SecurityContext happens to be present on the thread that calls schedule(...), which for most apps is the main thread during startup — not a real user, and not something you’d want re-used as the identity for every future run of the task. So this section isn’t really about propagation at all; it’s about minting an identity for work that never had one:
@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("Scheduled-");
scheduler.initialize();
// Propagates whatever SecurityContext is present when schedule() runs
// (typically none) -- not a substitute for the system context below
taskRegistrar.setTaskScheduler(
new DelegatingSecurityContextTaskScheduler(scheduler)
);
}
}
@Component
public class ScheduledTasks {
@Scheduled(cron = "0 0 * * * *")
public void scheduledCleanup() {
// For scheduled tasks, you might need to set a system context
SecurityContext systemContext = createSystemContext();
SecurityContextHolder.setContext(systemContext);
try {
performCleanup();
} finally {
SecurityContextHolder.clearContext();
}
}
private SecurityContext createSystemContext() {
Authentication systemAuth = new UsernamePasswordAuthenticationToken(
"SYSTEM", null, AuthorityUtils.createAuthorityList("ROLE_SYSTEM")
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(systemAuth);
return context;
}
}
That’s why scheduledCleanup() above builds its own SecurityContext from scratch with createSystemContext() instead of reading one from anywhere — there is nothing upstream to read. Give the synthetic identity the narrowest set of authorities the task actually needs (ROLE_SYSTEM here, not an admin role reused from somewhere else), so a bug in the scheduled task can’t do more damage than the task itself is supposed to. One more thing worth knowing if you’re on Boot 4.1: spring.threads.virtual.enabled=true also swaps the scheduler for a SimpleAsyncTaskScheduler backed by virtual threads, same as it does for @Async. The “fresh thread, not pooled” reasoning from the virtual-threads section above applies here too — but it changes nothing about this section’s actual point, since there is still no per-invocation user identity to inherit.
Testing Security Context Propagation
Testing async context propagation requires special attention. Use @WithMockUser or TestSecurityContextHolder:
@SpringBootTest
class AsyncServiceTest {
@Autowired
private AsyncService asyncService;
@Test
@WithMockUser(roles = {"ADMIN"})
void testAsyncWithMockUser() throws Exception {
CompletableFuture<String> future = asyncService.processSensitiveData();
// Wait for async completion
String result = future.get(5, TimeUnit.SECONDS);
assertThat(result).contains("admin");
}
@Test
void testAsyncWithManualContext() throws Exception {
// Setup security context manually
Authentication auth = new UsernamePasswordAuthenticationToken(
"testuser", "password", AuthorityUtils.createAuthorityList("ROLE_USER")
);
SecurityContextHolder.getContext().setAuthentication(auth);
try {
CompletableFuture<String> future = asyncService.processSensitiveData();
String result = future.get(5, TimeUnit.SECONDS);
assertThat(result).contains("testuser");
} finally {
SecurityContextHolder.clearContext();
}
}
}
@WebFluxTest
class ReactiveSecurityTest {
@Autowired
private WebTestClient webClient;
@Test
@WithMockUser
void testReactiveEndpoint() {
webClient.get()
.uri("/profile")
.exchange()
.expectStatus().isOk()
.expectBody(String.class)
.value(s -> assertThat(s).contains("user"));
}
}
Edge Cases This Guide Doesn’t Have Room For
Every example above is runnable code in the companion repo, spring-security-demo — including examples for sections this post only describes in prose: the classic pooled-thread @Async/ExecutorService/CompletableFuture wrappers, ReactiveSecurityContextHolder across a Reactor scheduler hop, DelegatingSecurityContextTaskScheduler’s actual capture semantics, and SecurityContextHolderFilter vs. SecurityContextPersistenceFilter against a real HttpSession. Building those turned up thirteen edge cases too narrow to justify their own section here. Each one line below links to the repo chapter that reproduces it against real code, not prose.
- A reused pool worker keeps two different tasks’ contexts separate under the
Delegating*wrappers — unlike plainInheritableThreadLocal, which leaks the previous task’s value onto a reused worker. docs/04 CompletableFuture.supplyAsync(supplier)with no executor argument silently uses the commonForkJoinPool, which never propagatesSecurityContext— the one-argument overload compiles fine and fails only this one way. docs/04MODE_INHERITABLETHREADLOCALis JVM-wide: fixing one virtual-thread executor with it also changes behavior for every other platform thread pool in the same process, and leaks the active context into background threads that were never meant to run as the current user. docs/02StructuredTaskScopeinheritsScopedValuebindings by specification and says nothing aboutThreadLocal—SecurityContextHoldergets nothing for free from afork()call, easy to assume otherwise since the forked subtask is a fresh virtual thread, the same shape that makesMODE_INHERITABLETHREADLOCALwork elsewhere. docs/03- There is no
DelegatingSecurityContextStructuredTaskScope, and there will not be one —StructuredTaskScopedoesn’t implementExecutor, so there’s noexecute(Runnable)seam for aDelegating*class to wrap. docs/03 - Reactor’s
Mono.map()throws aNullPointerExceptionif the mapper returnsnull— hit for real writing this repo’s own JUnit test, sinceSecurityContextHolder.getContext().getAuthentication()is legitimatelynullwhen nobody’s authenticated. docs/05 ReactiveSecurityContextHolder.getContext()completes empty, it does not error, when nothing was ever written upstream —defaultIfEmpty(...)is covering a real, reachable case, not defensive boilerplate. docs/05- A
ThreadLocalwrite survives inside one Reactor operator but not across apublishOnhop to a different scheduler — proven as a before/after comparison in the same chain. docs/05 DelegatingSecurityContextTaskScheduler’s single-argument constructor capturesSecurityContextHolder.getContext()fresh on everyschedule()call, not once when the wrapper is built — confirmed by reading the class’s bytecode before writing the demo, then proving it against the real class. docs/06- A synthetic
SYSTEMprincipal fromcreateSystemContext()is not “anonymous” toAuthenticationTrustResolver— authorization rules keyed onisAnonymous()will not match it. docs/06 SecurityContextHolderFilterhas no code path that ever callsSecurityContextRepository.saveContext(...)at all — confirmed by running it against a real session and watching nothing get written, then confirming the load side still works from a session an earlier explicit save populated. docs/07SecurityContextHolder.getContext()never returnsnull— an unauthenticated request gets an emptySecurityContextobject whosegetAuthentication()isnull. Code that checkscontext == nullto detect “nobody’s authenticated” is checking the wrong condition. docs/07TestSecurityContextHolderand productionSecurityContextHolderread and write the same underlying strategy — there’s no separate mock state to keep in sync between a@WithMockUser-style test and the code it exercises. docs/08
Summary and Best Practices
Security context propagation is essential for maintaining authentication and authorization across asynchronous boundaries. The key is to never assume the context automatically travels with your execution flow—always use the appropriate delegation wrapper for your concurrency model.
Past a certain point this stops being a list of frameworks and starts being one recurring question: where does the code that needs the security context actually run, and what does it accept? A mental model that covers everything above:
- Does it accept an
Executor,ExecutorService, orTaskScheduler? Wrap it with the matchingDelegating*class. This is correct on every JVM version and every thread model — it never depends on a global setting. - Is it a reactive chain (
Mono/Flux)? Don’t reach forSecurityContextHolderat all — useReactiveSecurityContextHolder, which travels with the ReactorContext, not a thread. - Is it a raw virtual thread or
StructuredTaskScope.fork()with noExecutorseam to wrap? Capture theSecurityContextand restore it manually inside the task, or useContextSnapshot.wrap(...)ifcontext-propagationis already on the classpath. - Do you control the executor bean and want every task on it — security context, MDC, tracing — propagated without touching call sites? Set
ContextPropagatingTaskDecoratoron it once. - Is there no caller at all (a
@Scheduledmethod, an event listener firing after the request that triggered it has finished)? None of the above apply — mint a synthetic system identity, as in the scheduled-tasks section.
MODE_INHERITABLETHREADLOCAL deliberately isn’t on this list. It’s a legitimate choice once virtual threads make the old pooled-thread danger go away, but it’s a blunt, JVM-wide instrument compared to the five options above, all of which are scoped to the exact executor or task that needs them. Reach for it only after you’ve confirmed nothing above fits, not as a first move.
Choose your strategy based on your application type:
| Application Type | Concurrency Model | Propagation Mechanism | Key Class |
|---|---|---|---|
| Servlet MVC | Thread-per-request | Automatic via Filter | SecurityContextHolderFilter |
| Servlet MVC with @Async | Thread pool | Executor wrapping | DelegatingSecurityContextAsyncTaskExecutor |
| Servlet MVC with ExecutorService | Manual thread pools | Task/Executor wrapping | DelegatingSecurityContextExecutorService |
| Servlet MVC with CompletableFuture | ForkJoinPool/Custom pools | Supply custom Executor | DelegatingSecurityContextExecutor |
| Reactive WebFlux | Event loop/Non-blocking | Reactor Context | ReactiveSecurityContextHolder |
| Scheduled Tasks | Background threads | Scheduler wrapping | DelegatingSecurityContextTaskScheduler |
| Servlet MVC with @Async (Boot 4.1, virtual threads) | Fresh virtual thread per task, never pooled | Task decorator (or MODE_INHERITABLETHREADLOCAL) | ContextPropagatingTaskDecorator |
| Structured concurrency | Fresh virtual thread per fork() |
Manual capture/restore or snapshot wrap | ContextSnapshot.wrap(...) |
Best Practices Checklist:
- Always clear the context after async operations complete to prevent memory leaks
- Never manually copy SecurityContext between threads—use delegation wrappers
- For scheduled tasks, create dedicated system-level authentication with minimal authorities
- In reactive applications, never use
ThreadLocaldirectly—always rely onReactiveSecurityContextHolder - Test async security paths thoroughly using
@WithMockUserand proper waiting mechanisms - Consider performance implications—context propagation adds minimal overhead but impacts thread pool configuration
- Audit your application for unwrapped Executors or
CompletableFutureusage that bypass security
By implementing these patterns consistently, you ensure that security policies are enforced across all execution paths, preventing unauthorized access in complex asynchronous workflows.
Two additions for the virtual-thread era specifically: don’t reach for MODE_INHERITABLETHREADLOCAL as a first move just because it happens to work with virtual threads — it’s still a JVM-wide setting, and ContextPropagatingTaskDecorator gives you the same result scoped to one executor. And never assume a StructuredTaskScope.fork() subtask carries your SecurityContext — it inherits ScopedValue bindings by specification, but SecurityContextHolder is a ThreadLocal, and that gap won’t show up until a secured call inside a forked subtask fails.
Every example in this guide — not just the two newer sections, all of it, including the classic @Async/ExecutorService/CompletableFuture wrappers, the WebFlux/ReactiveSecurityContextHolder example, scheduled tasks, and the servlet filter section — is verified, runnable code in the companion repo, not a paraphrase of what should happen: full source, eight cross-linked doc chapters, a ten-test JUnit suite, and captured console output for every scenario live in the context-propagation module of spring-security-demo.
Getting the SecurityContext onto the right thread is only half the job — something then has to read it. That is @PreAuthorize, and it has failure modes of its own that look nothing like the ones above: Method Security in Spring Security 7: @PreAuthorize, @PostAuthorize and the Proxy Traps covers the full SpEL surface and the three ways an annotated method runs with no check at all. Its companion code is the method-security module of the same repository. Note the one place the two topics meet directly: an @Async method carrying @PreAuthorize does not fail open, it throws AuthenticationCredentialsNotFoundException, because the pool thread never received the context this guide is about.
No Comments yet!