Your service leaks one user's identity into another user's request under load. Walk through how the SecurityContextHolder storage model could cause this and how you'd design propagation to prevent it (including virtual threads).
answer
- pooled thread reuse + residual context = cross-user leak
- INHERITABLETHREADLOCAL copies once -> stale identity
- set without finally-clear = dirty worker
- fix: MODE_THREADLOCAL + Delegating* per task
- virtual threads: same ThreadLocal rules, don't switch strategy
basics
~20 sIdentity leaks come from stale ThreadLocal state on reused threads: forgetting to clear the context, or using MODE_INHERITABLETHREADLOCAL with a pool so a worker keeps a context from an earlier request. Fix it with per-task propagation (DelegatingSecurityContext*) and ensuring the context is always cleared after each unit of work.
solid answer
~50 sThe holder stores the SecurityContext in thread-scoped state and pooled threads are reused across users, so any residual context becomes another user's identity. Two classic causes: (1) MODE_INHERITABLETHREADLOCAL with a thread pool — inheritance copies the context once at worker creation, so the worker keeps that first user's identity for all later tasks; (2) code that sets the context on a worker but doesn't clear it in a finally, so the next task on that thread inherits it. The robust design keeps MODE_THREADLOCAL, never relies on inheritance, and propagates explicitly per task via DelegatingSecurityContextExecutor / DelegatingSecurityContextCallable, which capture at submit and restore afterward. The servlet filter (SecurityContextHolderFilter) already clears per request. With Java 21 virtual threads, each task typically gets a fresh carrier-independent thread, but ThreadLocal semantics are unchanged — still use explicit propagation (or ScopedValue where appropriate) rather than inheritance.
code
java · 22 lines// WRONG: inheritance + pool -> workers carry a stale identity
// SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL); + @Async pool
// RIGHT: default ThreadLocal, propagate per task, always restored in finally
@Bean
Executor secureExecutor() {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.setThreadNamePrefix("work-");
pool.initialize();
return new DelegatingSecurityContextExecutor(pool); // capture@submit, restore@finally
}
// Manual propagation must mirror the framework's clear-in-finally discipline
void runAs(SecurityContext ctx, Runnable task) {
SecurityContext previous = SecurityContextHolder.getContext();
try {
SecurityContextHolder.setContext(ctx);
task.run();
} finally {
SecurityContextHolder.setContext(previous); // never leave the worker dirty
}
}go deeper
Recognize that reusing a thread with leftover context is dangerous; not expected to design the fix.
Identify INHERITABLETHREADLOCAL+pool and missing clear as causes and know the delegating wrappers fix it.
Design end-to-end propagation with capture/restore discipline and add regression coverage.
Set architectural guardrails (central executor wrapping, banned configs, tests), reason across servlet/reactive/virtual-thread models, and articulate why the strategy choice is invariant to the thread platform.
**Root cause: thread-scoped identity + thread reuse.** `SecurityContextHolder` stores the `SecurityContext` in `ThreadLocal` (or `InheritableThreadLocal`). Servlet containers and executors **pool and reuse** threads. If a thread finishes a unit of work while still holding a `SecurityContext`, the *next* unit of work on that same thread inherits it. When those two units of work belong to different users, user B now acts as user A — a serious authorization/isolation defect. **Failure mode 1 — INHERITABLETHREADLOCAL + pool.** `InheritableThreadLocal` copies the parent's value into a child **at child-thread creation time only**. A `ThreadPoolTaskExecutor` creates each worker once; that worker permanently carries whatever context existed when it was born (perhaps the first request that grew the pool), and never refreshes per task. Every subsequent task on that worker runs as that stale identity. This is the single most common self-inflicted leak and the reason inheritance mode is discouraged for servers. **Failure mode 2 — set without clear.** Custom code (or a hand-rolled propagation) does `SecurityContextHolder.setContext(ctx)` on a worker but doesn't `clearContext()` in a `finally`. The worker returns to the pool dirty. Any framework that forgets the finally, or an exception path that skips it, leaks. **Failure mode 3 — reading after clear / after commit.** The opposite bug: an async callback runs after `SecurityContextHolderFilter` already cleared the request context, so it reads empty. Less dangerous but a correctness bug. **The correct design:** 1. **Keep MODE_THREADLOCAL** (the default). Do not use inheritance to solve propagation. 2. **Propagate explicitly per task.** Use `DelegatingSecurityContextExecutor` / `...ExecutorService` / `DelegatingSecurityContextAsyncTaskExecutor` for pools, or `DelegatingSecurityContextRunnable` / `Callable` for individual tasks. They **capture at submit**, **set on the worker for the task's duration**, and **restore the prior value in finally** — correct even on reused threads. 3. **Rely on the framework's clearing.** `SecurityContextHolderFilter` (Spring Security 6; formerly `SecurityContextPersistenceFilter`) clears the holder in a finally at request end. Don't disable it. If you set context manually anywhere, mirror this: always clear in finally. 4. **For background/system work**, build a fresh context with `createEmptyContext()`, set a service-account `Authentication`, and pass it explicitly into the delegating wrapper — never mutate the request thread's context. **Virtual threads (Java 21+).** With `spring.threads.virtual.enabled=true`, each request/task tends to run on its own short-lived virtual thread, so pooling-style reuse largely goes away — which *reduces* the classic leak. But: (a) `ThreadLocal` still works the same on virtual threads, and if you install custom carrier/pool logic you can still leak; (b) `InheritableThreadLocal` inheritance semantics are unchanged and pinning/structured concurrency can surprise you; (c) the JDK's `ScopedValue` is the forward-looking, immutable, auto-scoped alternative to ThreadLocal that's a natural fit for structured concurrency, but Spring Security's core still centers on the ThreadLocal strategy. Practical guidance: **don't change propagation strategy just because you enabled virtual threads** — keep MODE_THREADLOCAL + explicit delegating propagation. It's correct in both models. **Detection & guardrails.** - Add an integration test that runs two requests as different users through your async path and asserts the identities don't cross. - Assert the holder is empty at task boundaries in a test harness. - Code-review ban on `SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL)` combined with pooled executors. - Prefer wrapping the executor bean centrally so developers can't submit un-propagated tasks by accident. **Reactive contrast.** In WebFlux there is no ThreadLocal identity; context travels in the Reactor `Context` via `ReactiveSecurityContextHolder`. The leak modes above are specific to the servlet/ThreadLocal world — but the design principle (identity is scoped to the unit of work, propagate explicitly, never leave residue) is universal.
- Why doesn't switching to virtual threads by itself fix (or require re-solving) this problem?Virtual threads reduce pooling-style reuse, which lessens the classic leak, but ThreadLocal semantics are unchanged and inheritance still copies once. The correct design — MODE_THREADLOCAL plus explicit DelegatingSecurityContext* propagation and clear-in-finally — is correct in both platform- and virtual-thread models, so you keep it.
- How would you write a regression test that catches an identity leak?Drive two concurrent requests as different authenticated users through the async/executor path and assert each downstream unit of work observes its own principal (and that the holder is empty at task boundaries). A cross-over proves residual context on a reused thread.
- What clears the SecurityContext at the end of a servlet request by default?SecurityContextHolderFilter (Spring Security 6; formerly SecurityContextPersistenceFilter) clears the holder in a finally block after the chain completes, so pooled request threads are returned clean.
saying these in an interview costs you the question
- Believing the JVM auto-clears ThreadLocals when a task/request finishes
- Proposing MODE_INHERITABLETHREADLOCAL as the fix for pooled async work
- Assuming virtual threads eliminate the need for explicit propagation and clearing
- Setting context on a worker without a finally that restores/clears it
- Using MODE_GLOBAL on a multi-user server