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.
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 automatically handles context propagation through the SecurityContextPersistenceFilter. This filter intercepts every request, loads the security context from the HTTP session, and binds it to the request-handling thread. After request completion, it clears the context to prevent thread contamination from the servlet container’s thread pool.
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())
// SecurityContextPersistenceFilter is automatically added
.securityContext(securityContext -> securityContext
.requireExplicitSave(true) // New in Spring Security 5.8+
);
return http.build();
}
}
With requireExplicitSave(true), you must manually save the context after authentication, giving you more control over when the context persists.
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 getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(securityContext -> {
Authentication auth = securityContext.getAuthentication();
return "Hello, " + auth.getName();
})
.defaultIfEmpty("Anonymous");
}
// Alternatively, use @AuthenticationPrincipal
@GetMapping("/user")
public Mono getUser(@AuthenticationPrincipal Mono 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 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 ExecutorService {
private final ExecutorService executorService;
public MyService() {
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 asyncOperation() {
return CompletableFuture.supplyAsync(() -> {
// Security context is preserved
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "Operation completed by: " + auth.getName();
}, executor);
}
}
Security Context in Scheduled Tasks
@Scheduled methods run on background threads managed by Spring’s task scheduler, lacking any security context since they’re not triggered by user requests. The solution is wrapping task runnables:
@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();
// Wrap scheduler to propagate context
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;
}
}
For scheduled tasks, you typically need to create a synthetic system authentication rather than expecting a user context.
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 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 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"));
}
}
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.
Choose your strategy based on your application type:
| Application Type | Concurrency Model | Propagation Mechanism | Key Class |
|---|---|---|---|
| Servlet MVC | Thread-per-request | Automatic via Filter | SecurityContextPersistenceFilter |
| 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 |
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.