Skip to main content
spring internals

SecurityContext Propagation and Thread Boundary Failures

7 min read Chapter 39 of 78

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:

  1. A pool thread created during request A carries request A’s SecurityContext.
  2. When request B’s task runs on that thread, it sees request A’s authentication.
  3. 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:

  1. Are you on the request thread? Print Thread.currentThread().getName(). If it starts with http-nio- or tomcat-, you are on the servlet thread. If it starts with async- or pool-, you are on a different thread.

  2. Is the method annotated with @Async? The method body runs on the pool thread, not the calling thread.

  3. What executor is configured? Check for DelegatingSecurityContextAsyncTaskExecutor in your @Configuration. If you use the default SimpleAsyncTaskExecutor, no context propagation occurs.

  4. Are you using CompletableFuture directly? Check which executor you pass to supplyAsync() or runAsync(). The default ForkJoinPool.commonPool() has no security context.

  5. 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.