Search results
Propagate HTTP Request Headers to Async Threads with ScopedValue in Spring Boot
Spring’s SecurityContextHolder doesn’t propagate to spawned threads. When async threads need to forward HTTP request headers — such as a JWT Bearer token — to downstream services, you need an explicit propagation mechanism.
3.2+ (for ContextPropagatingTaskDecorator) and Java 24+ for ScopedValue GA. Java
21–23 can use ScopedValue with --enable-preview.
AsyncContext.java:
public final class AsyncContext {
private AsyncContext() {}
public static final ScopedValue<String> JWT = ScopedValue.newInstance();
}
AsyncContextPropagator.java:
@Component
@RequiredArgsConstructor
public class AsyncContextPropagator {
private final AsyncTaskExecutor taskExecutor;
private final ObjectProvider<HttpSession> httpSessionProvider;
public <T> CompletableFuture<T> run(Supplier<T> task) {
String jwt = this.resolveJwt();
return CompletableFuture.supplyAsync(() -> this.withJwt(jwt, task), this.taskExecutor);
}
private String resolveJwt() {
HttpSession httpSession = this.httpSessionProvider.getIfAvailable();
return (httpSession != null) ? (String) httpSession.getAttribute("jwt") : null;
}
private <T> T withJwt(String jwt, Supplier<T> task) {
if (jwt == null) {
return task.get();
}
try {
return ScopedValue.where(AsyncContext.JWT, jwt).call(task::get);
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
}
-
resolveJwt() reads the JWT from the HTTP session on the request thread, before handing off to the async thread.
ObjectProvider returnsnullgracefully when there is no active session, such as in scheduled jobs. -
withJwt() binds the captured JWT to AsyncContext.JWT via ScopedValue.where(…).call(…).
ScopedValue.call() throws checked Exception, so only checked exceptions are wrapped into RuntimeException to keep lambdas clean.
Add ContextPropagatingTaskDecorator to the AsyncTaskExecutor bean to also propagate Spring’s ThreadLocal-backed context (e.g. SecurityContextHolder, RequestContextHolder) alongside ScopedValue.
It uses Spring’s ThreadLocalAccessor SPI to snapshot all registered ThreadLocal-backed contexts from the submitting thread and restore them in the worker thread before the task runs.
Virtual threads:
@Bean
public AsyncTaskExecutor taskExecutor() {
ThreadFactory factory = Thread.ofVirtual().name(this.threadNamePrefix, 0).factory();
Executor virtualExecutor = Executors.newThreadPerTaskExecutor(factory);
ContextPropagatingTaskDecorator decorator = new ContextPropagatingTaskDecorator();
return new TaskExecutorAdapter(executor -> virtualExecutor.execute(decorator.decorate(executor)));
}
Thread pool:
@Bean
public AsyncTaskExecutor taskExecutor(TaskExecutionProperties taskExecutionProps) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// ... pool size, queue capacity, thread name prefix from taskExecutionProps ...
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
executor.initialize();
return executor;
}
-
Virtual thread bean: TaskExecutorAdapter wraps a raw Executor, which has no setTaskDecorator() hook.
The decorator must be applied manually —decorator.decorate(runnable)wraps each submitted Runnable before it reaches the virtual thread executor. -
Thread pool bean: ThreadPoolTaskExecutor has first-class TaskDecorator support via setTaskDecorator(), so the decorator is set directly and applied automatically to every submitted task.
Usage
Inject AsyncContextPropagator into a service and call run() to fire parallel async tasks that carry request headers, like JWT Bearer token:
DefaultPlayerActivityService.java:
@Service
@RequiredArgsConstructor
public class DefaultPlayerActivityService implements PlayerActivityService {
private final TrainingSessionRepository trainingSessionRepository;
private final PeriodStatsRepository periodStatsRepository;
private final AsyncContextPropagator asyncContextPropagator;
@Override
public PlayerDashboardDto retrievePlayerDashboard(UUID playerProfileId) {
CompletableFuture<List<TrainingSessionSummary>> sessionsFuture = this.asyncContextPropagator.run(
() -> this.trainingSessionRepository.findByPlayerProfileId(playerProfileId)
);
CompletableFuture<List<PeriodStatsSummary>> reportsFuture = this.asyncContextPropagator.run(
() -> this.periodStatsRepository.findByPlayerProfileId(playerProfileId)
);
CompletableFuture.allOf(sessionsFuture, reportsFuture).join();
return new PlayerDashboardDto(sessionsFuture.join(), reportsFuture.join());
}
}
In the RestClient interceptor, check AsyncContext.JWT.isBound() to decide where to read the JWT from:
WebClientConfig.java:
private ClientHttpRequestInterceptor jwtAuthInterceptor(ObjectProvider<HttpSession> httpSessionProvider) {
return (request, body, execution) -> {
String jwt;
if (AsyncContext.JWT.isBound()) {
// Async thread: JWT was captured on the request thread and bound via ScopedValue
jwt = AsyncContext.JWT.get();
} else {
// Request thread: read from session
HttpSession httpSession = httpSessionProvider.getIfAvailable();
jwt = (httpSession != null) ? (String) httpSession.getAttribute("jwt") : null;
}
if (jwt != null) {
request.getHeaders().setBearerAuth(jwt);
}
return execution.execute(request, body);
};
}
Sample logs
... [mcat-handler-16] [6a8c9440d6b6f523302598c83eaf537f-26d4b06b260ff59a] ... : Outgoing request: GET http://localhost:8080/api/player-profiles?limit=1
... [mcat-handler-16] [6a8c9440d6b6f523302598c83eaf537f-26d4b06b260ff59a] ... : Request headers: [Accept:"application/json", traceparent:"00-6a8c9440d6b6f523302598c83eaf537f-26d4b06b260ff59a-01", Content-Length:"0", Authorization:"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIzMWIzMDA1NC1mOWVkLTQ5NTUtODdiZi1kOTUwNjU2MzEwNzAiLCJmaXJzdE5hbWUiOiJKYXNvbiIsImV4cCI6MTc4NzYwMDk0MCwiaWF0IjoxNzg3NTk3MzQwLCJzY29wZSI6WyJST0xFX1BMQVlFUiJdfQ.upHJ0ICMtPwIHHvm7oxA-uC_ag_3HsEZm6eKqKX77UA"]
...
... [irtual-thread-1] [6a8c9440d6b6f523302598c83eaf537f-1649b16c25d84091] ... : Outgoing request: GET http://localhost:8080/api/training-sessions?playerProfileId=019f3da3-c41b-7eb2-838c-367972507a80&limit=5
... [irtual-thread-1] [6a8c9440d6b6f523302598c83eaf537f-1649b16c25d84091] ... : Request headers: [Accept:"application/json", traceparent:"00-6a8c9440d6b6f523302598c83eaf537f-1649b16c25d84091-01", Content-Length:"0", Authorization:"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIzMWIzMDA1NC1mOWVkLTQ5NTUtODdiZi1kOTUwNjU2MzEwNzAiLCJmaXJzdE5hbWUiOiJKYXNvbiIsImV4cCI6MTc4NzYwMDk0MCwiaWF0IjoxNzg3NTk3MzQwLCJzY29wZSI6WyJST0xFX1BMQVlFUiJdfQ.upHJ0ICMtPwIHHvm7oxA-uC_ag_3HsEZm6eKqKX77UA"]
...
... [irtual-thread-2] [6a8c9440d6b6f523302598c83eaf537f-2b28a61e10416dd8] ... : Outgoing request: GET http://localhost:8080/api/player-profiles/019f3da3-c41b-7eb2-838c-367972507a80/period-stats?periodType=DAILY&limit=5
... [irtual-thread-2] [6a8c9440d6b6f523302598c83eaf537f-2b28a61e10416dd8] ... : Request headers: [Accept:"application/json", traceparent:"00-6a8c9440d6b6f523302598c83eaf537f-2b28a61e10416dd8-01", Content-Length:"0", Authorization:"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIzMWIzMDA1NC1mOWVkLTQ5NTUtODdiZi1kOTUwNjU2MzEwNzAiLCJmaXJzdE5hbWUiOiJKYXNvbiIsImV4cCI6MTc4NzYwMDk0MCwiaWF0IjoxNzg3NTk3MzQwLCJzY29wZSI6WyJST0xFX1BMQVlFUiJdfQ.upHJ0ICMtPwIHHvm7oxA-uC_ag_3HsEZm6eKqKX77UA"]
...
