Spring Security: SecurityContext Storage and Thread Propagation
Objective
Once AuthenticationManager finishes authenticating a request, that result has to
live somewhere the rest of the request — controllers, services, anything downstream
— can read it back. Spring Security calls that place the security context, and
SecurityContextHolder is the object responsible for storing and handing it back.
The interesting part isn't the storage itself, it's which thread can see it:
the default behavior isolates each request's context to its own thread, which is
exactly right for a normal request/response cycle and exactly wrong the moment a
task is handed off to another thread (@Async, a manual ExecutorService, a
scheduled job).
Use Cases
- Reading the currently authenticated user's name/authorities from any component
reached during a request, without threading the
Authenticationobject through every method call as a parameter. - Making an
@Asyncmethod see the sameAuthenticationas the request that triggered it, instead of hitting aNullPointerExceptionon anullAuthentication. - Running background/self-managed threads (a manually created
ExecutorService, a thread pool your own code owns) that still need to know who the original caller was. - Deciding, for a standalone (non-web) application, whether every thread should share one global security context instead of per-thread isolation.
Deep Dive
The SecurityContext contract and its default home: MODE_THREADLOCAL
javapublic interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}SecurityContextHolder manages instances of this contract via one of three
strategies. The default, MODE_THREADLOCAL, backs it with the JDK's own
ThreadLocal: each thread sees only its own security context, so in a normal
thread-per-request servlet application, each request's authenticated identity
stays isolated from every other concurrent request automatically, with zero
configuration:
java@GetMapping("/hello")
public String hello() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication a = context.getAuthentication();
return "Hello, " + a.getName() + "!";
}Spring can also inject the Authentication directly as a method parameter,
skipping the explicit SecurityContextHolder.getContext() call entirely:
java@GetMapping("/hello")
public String hello(Authentication a) {
return "Hello, " + a.getName() + "!";
}Where MODE_THREADLOCAL breaks: a new thread has an empty context
ThreadLocal isolation is a feature for concurrent requests and a problem the
moment one request spawns a second thread. An @Async method runs on a thread the
web container didn't create for this request, so it starts with its own, empty
security context:
java@GetMapping("/bye")
@Async
public void goodbye() {
SecurityContext context = SecurityContextHolder.getContext();
String username = context.getAuthentication().getName();
// throws NullPointerException — the async thread's context is empty
}Fixing @Async: MODE_INHERITABLETHREADLOCAL
Switching strategy tells Spring Security to copy the context from the parent
thread to any new thread the framework itself creates (an @Async method,
concretely):
java@Configuration
@EnableAsync
public class ProjectConfig {
@Bean
public InitializingBean initializingBean() {
return () -> SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
}With this strategy active, the same @Async method above sees a populated
Authentication instead of null. The strategy only helps when Spring Security
itself is the one creating the new thread — it does nothing for threads your own
code spins up, which is a separate problem covered below.
MODE_GLOBAL: one context for every thread (standalone apps only)
A third strategy makes every thread in the application share the exact same security context instance:
java@Bean
public InitializingBean initializingBean() {
return () -> SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_GLOBAL);
}This fits a standalone (non-web) application where there's no meaningful notion of
"one request, one identity" to isolate in the first place. It's a poor fit for a
backend web server: every concurrent request would see and could mutate the same
shared, non-thread-safe SecurityContext, which is a race condition waiting to
happen rather than a convenience.
Self-managed threads: DelegatingSecurityContextCallable/Runnable
Neither MODE_INHERITABLETHREADLOCAL nor MODE_GLOBAL helps when the application's
own code creates a thread the framework doesn't know about — a manually built
ExecutorService, for instance:
java@GetMapping("/ciao")
public String ciao() throws Exception {
Callable<String> task = () -> {
SecurityContext context = SecurityContextHolder.getContext();
return context.getAuthentication().getName();
};
ExecutorService e = Executors.newCachedThreadPool();
try {
return "Ciao, " + e.submit(task).get() + "!";
} finally {
e.shutdown();
}
}As written, this throws a NullPointerException — the pool's thread was never
told about the request's context. Wrapping the task in
DelegatingSecurityContextCallable (or DelegatingSecurityContextRunnable for a
no-return-value task) copies the calling thread's security context onto the task
itself, so it travels with the task regardless of which thread finally runs it:
javaExecutorService e = Executors.newCachedThreadPool();
try {
var contextTask = new DelegatingSecurityContextCallable<>(task);
return "Ciao, " + e.submit(contextTask).get() + "!";
} finally {
e.shutdown();
}Propagating from the pool instead of the task: DelegatingSecurityContextExecutorService
The alternative to decorating every individual task is to decorate the executor
itself once — the task stays a plain Callable, and the wrapped executor takes
care of context propagation for every task it runs:
javaExecutorService e = Executors.newCachedThreadPool();
e = new DelegatingSecurityContextExecutorService(e);
try {
return "Hola, " + e.submit(task).get() + "!";
} finally {
e.shutdown();
}Spring Security ships the same idea at different levels of the Executor
hierarchy: DelegatingSecurityContextExecutor (wraps the plain Executor
interface), DelegatingSecurityContextExecutorService (wraps ExecutorService),
and DelegatingSecurityContextScheduledExecutorService (wraps
ScheduledExecutorService, for scheduled tasks) — pick the one matching the
executor type already in use rather than decorating each submitted task by hand.
Trade-offs
MODE_THREADLOCAL's per-thread isolation is exactly what a web server wants, and exactly what breaks the moment a second thread enters the picture. It needs no configuration for the common case, but any hand-off to another thread —@Async, a manual pool, a scheduled job — starts from an empty context unless one of the other mechanisms in this concept is used.MODE_INHERITABLETHREADLOCALonly covers threads Spring Security itself creates. It solves@Asynccleanly, but silently does nothing for a thread your own code starts — that case needs theDelegatingSecurityContext*decorators instead, and reaching for the wrong tool produces the sameNullPointerExceptioneither way.MODE_GLOBALtrades per-request isolation for shared, mutable state. Every thread reading and writing the sameSecurityContextinstance is appropriate for a standalone application with no concurrent, independent requests to keep separate — in a web server, the same property becomes a race condition, sinceSecurityContextitself is documented as not thread-safe.- Decorating the task vs. decorating the executor is a "where do you want the
responsibility to live" choice, not a correctness difference.
DelegatingSecurityContextCallablecouples propagation to each call site;DelegatingSecurityContextExecutorServicecentralizes it once, at the pool — the latter scales better when many call sites submit to the same pool, since none of them need to remember to wrap their task. - Book vs. today: setting the strategy via
SecurityContextHolder.setStrategyName()is still valid but is no longer the primary recommended customization path. Since Spring Security 5.8, the reference documentation recommends publishing aSecurityContextHolderStrategybean in the application context instead of relying onSecurityContextHolder's static, classloader-wide strategy — the docs note static access "can create race conditions when there are multiple application contexts that want to specify theSecurityContextHolderStrategy," sinceSecurityContextHolderholds one strategy per classloader rather than per application context. Components can now autowire aSecurityContextHolderStrategyand call instance methods (createEmptyContext(),setContext()) on it instead of the staticSecurityContextHolder.getContext()/setContext()calls the book uses throughout this section — confirmed via the current Spring Security reference. This is a "newer recommended path added since," not a removal: everyMODE_*constant and the static API the book demonstrates still works exactly as described.
Documentation Links
- Laurențiu Spilcă, "Spring Security in Action" (Manning, 2020) — Chapter 5, "Implementing authentication", section 5.2, p. 113-124
- Spring Security Reference — Servlet Authentication Architecture (SecurityContextHolder)
- Spring Security Reference — Authentication Persistence and Session Management (SecurityContextHolderStrategy)
- Spring Security API — SecurityContextHolderStrategy