When we started parallelizing API calls in our Spring Boot service using CompletableFuture
and a custom ExecutorService
, everything looked great… until we checked the logs.
- Our JWT
SecurityContext
wasn’t available in the async threads. - Our MDC correlation IDs (used for distributed tracing/log correlation) were missing too.
That meant downstream services didn’t know who was calling, and our logs lost the ability to tie requests together. Not good.
🚨 The Problem
Spring Security stores authentication in a ThreadLocal
(SecurityContextHolder
).
SLF4J’s MDC (Mapped Diagnostic Context) also uses ThreadLocal
to store correlation IDs.
When you hop threads (e.g., via CompletableFuture.supplyAsync
), those ThreadLocal
values don’t magically follow along. So in worker threads:
-
SecurityContextHolder.getContext()
→ empty -
MDC.get("correlationId")
→ null
✅ The Solution: Wrap the Executor
We solved this by wrapping our ExecutorService
in a lightweight decorator that captures the MDC + SecurityContext from the submitting thread and restores them inside the worker thread.
Here’s the implementation:
public class ContextPropagatingExecutorService extends AbstractExecutorService {
private final ExecutorService delegate;
public ContextPropagatingExecutorService(ExecutorService delegate) {
this.delegate = delegate;
}
private Runnable wrap(Runnable task) {
final Map<String, String> mdc = MDC.getCopyOfContextMap();
final SecurityContext securityContext = SecurityContextHolder.getContext();
return () -> {
if (mdc != null) MDC.setContextMap(mdc);
if (securityContext != null) SecurityContextHolder.setContext(securityContext);
try {
task.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
};
}
@Override
public void execute(Runnable command) {
delegate.execute(wrap(command));
}
// delegate lifecycle methods...
}
⚙️ Wiring It Up
In our Spring Boot config:
@Configuration
public class AsyncConfig {
@Bean(name = "executorService")
public ExecutorService executorService() {
ExecutorService base = Executors.newCachedThreadPool();
return new ContextPropagatingExecutorService(base);
}
}
Now, whenever we do:
CompletableFuture<AccountDTO> fromAccount =
CompletableFuture.supplyAsync(() -> accountClient.getAccountsById(id), executorService);
…the async thread has the same SecurityContext and MDC as the request thread.
📊 Before vs After
Aspect | Before | After |
---|---|---|
SecurityContextHolder.getContext() |
Empty in async thread | Correctly populated |
MDC.get("correlationId") |
null |
Same correlation ID as request thread |
Logs | Missing trace IDs | Full traceability across async calls |
Downstream services | No JWT propagated | JWT available for Feign/RestTemplate |
🔑 Takeaways
- ThreadLocals don’t cross thread boundaries — you need to propagate them manually.
- Wrapping your
ExecutorService
is a clean, reusable fix. - This pattern works not just for MDC + SecurityContext, but for any contextual data you need across async boundaries.
🚀 Closing Thoughts
If you’re building microservices with Spring Boot and using async execution (CompletableFuture
, @Async
, Kafka listeners, etc.), don’t forget about context propagation. Without it, your logs and security checks will silently break.
Wrapping your executor is a small change that pays off big in observability and security consistency.
Top comments (0)