SecurityContext Propagation and Thread Boundary Failures
SecurityContext Propagation and Thread Boundary Failures
The entire Spring Security method authorization model depends on SecurityContextHolder returning the correct Authentication when called. In a single-threaded request, this works because the servlet container’s thread carries the SecurityContext from the filter chain through the controller, into the service layer, and back. The moment work moves to another thread, that guarantee disappears.
This section catalogs every way the SecurityContext gets lost, explains why each propagation strategy exists, and gives you the correct fix for each scenario.
SecurityContextHolder: Three Storage Strategies
SecurityContextHolder is a static utility class. It delegates storage to a SecurityContextHolderStrategy. Three implementations exist:
MODE_THREADLOCAL (default): Stores the SecurityContext in a ThreadLocal<SecurityContext>. Each thread has its own context. Threads do not share. This is correct for servlet-based applications where one thread handles one request.
MODE_INHERITABLETHREADLOCAL: Stores the context in an InheritableThreadLocal<SecurityContext>. Child threads inherit the parent’s context at creation time. This seems like a solution for async processing. It is not.
MODE_GLOBAL: Stores the context in a single static field. All threads share the same context. This is only appropriate for desktop applications or batch jobs with a single authenticated principal.
Set the strategy at application startup:
@SpringBootApplication
public class SaasApplication {
public static void main(String[] args) {
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
SpringApplication.run(SaasApplication.class, args);
}
}
The strategy must be set before the first SecurityContext is stored. Setting it after the filter chain has already populated a context produces undefined behavior.
The Default Failure: @Async and MODE_THREADLOCAL
The SaaS backend sends notification emails asynchronously after order processing. The notification service needs the authenticated user’s email and tenant ID:
// BROKEN: SecurityContext is null on the async thread
@Service
@RequiredArgsConstructor
public class NotificationService {
private final EmailClient emailClient;
private final AuditRepository auditRepository;
@Async
public CompletableFuture<Void> sendOrderConfirmation(
TenantId tenantId, OrderId orderId) {
Authentication auth = SecurityContextHolder
.getContext()
.getAuthentication();
// NullPointerException: auth is null
SaasUserDetails user = (SaasUserDetails) auth.getPrincipal();
emailClient.send(
user.getEmail(),
"Order " + orderId.value() + " confirmed",
buildConfirmationBody(orderId)
);
auditRepository.save(
AuditEntry.notification(tenantId, user.getUsername(), orderId));
return CompletableFuture.completedFuture(null);
}
}
The calling code in OrderService:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
@PreAuthorize("hasRole('USER')")
@Transactional
public OrderResult processOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
// This returns immediately; notification runs on a pool thread
notificationService.sendOrderConfirmation(tenantId, order.getId());
return OrderResult.of(order);
}
}
The @Async annotation causes Spring to execute sendOrderConfirmation on a thread from the async executor pool. The pool thread’s ThreadLocal does not contain the SecurityContext. getAuthentication() returns null. The call fails.
The @PreAuthorize on processOrder passes because it runs on the servlet thread where the context exists. But the context does not follow the task to the pool thread.
Why MODE_INHERITABLETHREADLOCAL Does Not Fix Thread Pools
InheritableThreadLocal copies the parent thread’s value to a child thread when the child is created. For a new thread spawned with new Thread(), this works:
// This works with MODE_INHERITABLETHREADLOCAL
new Thread(() -> {
Authentication auth = SecurityContextHolder
.getContext()
.getAuthentication();
// auth is not null: inherited from parent
}).start();
Thread pools break this. A ThreadPoolTaskExecutor creates threads once and reuses them. The thread that runs your async task was created minutes or hours ago, during pool initialization or when handling a previous request. The InheritableThreadLocal value was set at thread creation time, not at task submission time.
The consequences:
- A pool thread created during request A carries request A’s
SecurityContext. - When request B’s task runs on that thread, it sees request A’s authentication.
- Request B’s code now operates under request A’s identity.
This is a cross-request identity leak. It is intermittent, depends on pool size and request timing, and is nearly impossible to reproduce in single-threaded tests. In production with 50 concurrent users, one user will periodically see another user’s data or perform operations under another user’s permissions.
Do not use MODE_INHERITABLETHREADLOCAL with thread pools.
The Correct Fix: DelegatingSecurityContextAsyncTaskExecutor
The proper solution copies the SecurityContext at task submission time, not at thread creation time. DelegatingSecurityContextAsyncTaskExecutor wraps each submitted task in a DelegatingSecurityContextRunnable:
// CORRECT: SecurityContext copied at submission time
@Configuration
@EnableAsync
public class AsyncSecurityConfig {
@Bean
public AsyncTaskExecutor asyncTaskExecutor() {
ThreadPoolTaskExecutor delegate = new ThreadPoolTaskExecutor();
delegate.setCorePoolSize(5);
delegate.setMaxPoolSize(20);
delegate.setQueueCapacity(200);
delegate.setThreadNamePrefix("saas-async-");
delegate.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
}
What DelegatingSecurityContextRunnable does:
// Simplified from DelegatingSecurityContextRunnable
public final class DelegatingSecurityContextRunnable implements Runnable {
private final Runnable delegate;
private final SecurityContext securityContext;
public DelegatingSecurityContextRunnable(Runnable delegate) {
this.delegate = delegate;
// Capture at construction time (on the calling thread)
this.securityContext = SecurityContextHolder.getContext();
}
@Override
public void run() {
try {
// Set on the worker thread before task execution
SecurityContextHolder.setContext(securityContext);
delegate.run();
} finally {
// Clear after task execution to prevent leaking
SecurityContextHolder.clearContext();
}
}
}
The finally block is critical. Without it, the SecurityContext from this task would remain on the pool thread and leak into the next task that reuses the thread. The same cross-request identity problem, but caused by the fix instead of the bug.
Now the notification service works:
@Async("asyncTaskExecutor")
public CompletableFuture<Void> sendOrderConfirmation(
TenantId tenantId, OrderId orderId) {
Authentication auth = SecurityContextHolder
.getContext()
.getAuthentication();
// auth is the caller's authentication, propagated by the wrapper
SaasUserDetails user = (SaasUserDetails) auth.getPrincipal();
emailClient.send(user.getEmail(), "Order " + orderId.value() + " confirmed",
buildConfirmationBody(orderId));
return CompletableFuture.completedFuture(null);
}
CompletableFuture Without @Async
If you use CompletableFuture.supplyAsync() directly instead of @Async, you must wrap the executor yourself:
// BROKEN: default ForkJoinPool has no SecurityContext
CompletableFuture.supplyAsync(() -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// auth is null
return processWithAuth(auth);
});
// CORRECT: use DelegatingSecurityContextExecutor
Executor secureExecutor = new DelegatingSecurityContextExecutor(
Executors.newFixedThreadPool(4));
CompletableFuture.supplyAsync(() -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// auth is propagated
return processWithAuth(auth);
}, secureExecutor);
Or wrap the Runnable directly:
CompletableFuture.supplyAsync(
new DelegatingSecurityContextRunnable(() -> processWithAuth()),
executor
);
Reactive Pipelines: ReactorContextWebFilter
In WebFlux applications, there is no ThreadLocal. The reactive pipeline moves work across threads at every operator boundary. Spring Security for WebFlux stores the SecurityContext in the Reactor Context, which is a key-value store attached to the reactive subscription.
ReactorContextWebFilter writes the SecurityContext into the Reactor context so that downstream operators can access it with ReactiveSecurityContextHolder.getContext():
// WebFlux: reading SecurityContext in a reactive pipeline
@PreAuthorize("hasRole('USER')")
public Mono<Order> getOrder(TenantId tenantId, OrderId orderId) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication())
.flatMap(auth -> {
SaasUserDetails user = (SaasUserDetails) auth.getPrincipal();
if (!user.getTenantId().equals(tenantId)) {
return Mono.error(new AccessDeniedException("Wrong tenant"));
}
return orderRepository.findById(orderId);
});
}
This is a different mechanism from ThreadLocal. It is covered in depth in CH16. For servlet-based applications, you will not use ReactorContextWebFilter. The patterns above with DelegatingSecurityContextAsyncTaskExecutor are the correct approach.
Java 21 Structured Concurrency
Java 21 introduced StructuredTaskScope as a preview feature. Structured concurrency creates child threads that are logically scoped to a parent task. This model aligns better with SecurityContext propagation because child tasks have a defined relationship to the parent.
However, Spring Security does not yet integrate with structured concurrency natively. The ScopedValue API (also preview in Java 21) is designed to replace ThreadLocal for cases exactly like SecurityContext propagation. ScopedValue is automatically inherited by child threads in a StructuredTaskScope.
Until Spring Security adds ScopedValue support, the practical approach for Java 21 applications is the same: use DelegatingSecurityContextAsyncTaskExecutor for @Async and DelegatingSecurityContextExecutor for manual thread management. Watch for updates in Spring Security 7.
Diagnostic Checklist
When SecurityContextHolder.getContext().getAuthentication() returns null:
-
Are you on the request thread? Print
Thread.currentThread().getName(). If it starts withhttp-nio-ortomcat-, you are on the servlet thread. If it starts withasync-orpool-, you are on a different thread. -
Is the method annotated with @Async? The method body runs on the pool thread, not the calling thread.
-
What executor is configured? Check for
DelegatingSecurityContextAsyncTaskExecutorin your@Configuration. If you use the defaultSimpleAsyncTaskExecutor, no context propagation occurs. -
Are you using CompletableFuture directly? Check which executor you pass to
supplyAsync()orrunAsync(). The defaultForkJoinPool.commonPool()has no security context. -
Is MODE_INHERITABLETHREADLOCAL set with a thread pool? This is a security vulnerability. Switch to
DelegatingSecurityContextAsyncTaskExecutor.
The fix is always the same pattern: wrap the executor so that the SecurityContext is copied at task submission time, set before task execution, and cleared after task completion.