Skip to main content

Spring Security Context Propagation: The Complete Guide (With Virtual Threads & Structured Concurrency)

Spring Security’s SecurityContext is the cornerstone of authentication and authorization in Spring applications, but propagating it across thread boundaries is a real trap in async, reactive, and now virtual-thread code. This guide covers ThreadLocal strategies, servlet and WebFlux propagation, @Async/ExecutorService/CompletableFuture, and—updated for Boot 4.1—virtual threads, DelegatingSecurityContextExecutor, ContextPropagatingTaskDecorator, and StructuredTaskScope, each verified against real, captured output.

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 thread
  • MODE_INHERITABLETHREADLOCAL: Context is inherited by child threads created by the current thread
  • MODE_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):

A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): NO AUTHENTICATION (lost)
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):

A) plain fork, MODE_THREADLOCAL: NO AUTHENTICATION (lost)
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 plain InheritableThreadLocal, which leaks the previous task’s value onto a reused worker. docs/04
  • CompletableFuture.supplyAsync(supplier) with no executor argument silently uses the common ForkJoinPool, which never propagates SecurityContext — the one-argument overload compiles fine and fails only this one way. docs/04
  • MODE_INHERITABLETHREADLOCAL is 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/02
  • StructuredTaskScope inherits ScopedValue bindings by specification and says nothing about ThreadLocalSecurityContextHolder gets nothing for free from a fork() call, easy to assume otherwise since the forked subtask is a fresh virtual thread, the same shape that makes MODE_INHERITABLETHREADLOCAL work elsewhere. docs/03
  • There is no DelegatingSecurityContextStructuredTaskScope, and there will not be one — StructuredTaskScope doesn’t implement Executor, so there’s no execute(Runnable) seam for a Delegating* class to wrap. docs/03
  • Reactor’s Mono.map() throws a NullPointerException if the mapper returns null — hit for real writing this repo’s own JUnit test, since SecurityContextHolder.getContext().getAuthentication() is legitimately null when 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 ThreadLocal write survives inside one Reactor operator but not across a publishOn hop to a different scheduler — proven as a before/after comparison in the same chain. docs/05
  • DelegatingSecurityContextTaskScheduler’s single-argument constructor captures SecurityContextHolder.getContext() fresh on every schedule() 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 SYSTEM principal from createSystemContext() is not “anonymous” to AuthenticationTrustResolver — authorization rules keyed on isAnonymous() will not match it. docs/06
  • SecurityContextHolderFilter has no code path that ever calls SecurityContextRepository.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/07
  • SecurityContextHolder.getContext() never returns null — an unauthenticated request gets an empty SecurityContext object whose getAuthentication() is null. Code that checks context == null to detect “nobody’s authenticated” is checking the wrong condition. docs/07
  • TestSecurityContextHolder and production SecurityContextHolder read 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, or TaskScheduler? Wrap it with the matching Delegating* 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 for SecurityContextHolder at all — use ReactiveSecurityContextHolder, which travels with the Reactor Context, not a thread.
  • Is it a raw virtual thread or StructuredTaskScope.fork() with no Executor seam to wrap? Capture the SecurityContext and restore it manually inside the task, or use ContextSnapshot.wrap(...) if context-propagation is 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 ContextPropagatingTaskDecorator on it once.
  • Is there no caller at all (a @Scheduled method, 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 ThreadLocal directly—always rely on ReactiveSecurityContextHolder
  • Test async security paths thoroughly using @WithMockUser and proper waiting mechanisms
  • Consider performance implications—context propagation adds minimal overhead but impacts thread pool configuration
  • Audit your application for unwrapped Executors or CompletableFuture usage 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!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.