Spring WebMVC Async and Stripe Integration: How DeferredResult Task Dispatch, MODE_INHERITABLETHREADLOCAL in Thread Pools, and @Async/@Retryable Interaction Generate New Idempotency Keys on Retry
Spring WebMVC async controllers introduce three Stripe idempotency failure modes that are invisible in synchronous Spring MVC and structurally different from the reactive failure modes in Spring WebFlux and Spring Cloud Gateway. The three modes are: DeferredResult<?> executor threads do not receive SecurityContext via the mechanism that covers Callable<?> controllers — SecurityContextHolder.getContext().getAuthentication() returns null on the executor thread — a defensive null-check generates UUID.randomUUID() per invocation — @Retryable produces UUID_B on the retry where Stripe creates ch_B; the common workaround of enabling MODE_INHERITABLETHREADLOCAL appears to solve the propagation gap but InheritableThreadLocal only propagates at thread creation time — thread-pool threads were created at application startup before any request arrived — the fix does nothing for pre-existing pooled threads; and placing @Async and @Retryable on the same billing method causes @Retryable to see a CompletableFuture return without an exception and never retry, leading the developer to split the annotations across two beans — the inner @Retryable bean then runs on the executor thread with no SecurityContext — UUID fallback fires on every attempt — double charge.
Background: how Spring MVC async processing propagates SecurityContext for Callable<?> but not for DeferredResult<?>
Spring MVC supports two primary async return types from controller methods: Callable<T> and DeferredResult<T>. Both allow the Tomcat request-handling thread to be released while processing continues asynchronously, but the processing model is different enough that Spring Security handles them through distinct mechanisms — and only one of those mechanisms propagates SecurityContext automatically.
When a controller returns Callable<T>, Spring MVC extracts the callable and submits it to a TaskExecutor via WebAsyncManager. Before submitting, Spring MVC invokes the registered CallableProcessingInterceptor chain. Spring Security registers SecurityContextCallableProcessingInterceptor — which captures the current request thread’s SecurityContext at beforeConcurrentHandling() time and restores it on the executor thread at preProcess() time. This means the callable body runs with the same SecurityContext that the request thread had when the controller method was invoked. The developer does not need to do anything extra; the callable sees SecurityContextHolder.getContext().getAuthentication() as if it were on the original request thread.
When a controller returns DeferredResult<T>, Spring MVC registers the result object with WebAsyncManager and releases the request thread. There is no callable to intercept — the developer is responsible for calling deferredResult.setResult() or deferredResult.setErrorResult() from wherever the async work is done. Spring Security has no hook in this path: SecurityContextCallableProcessingInterceptor does not fire, and no other mechanism copies SecurityContext to whatever thread the developer uses to complete the result. If the developer submits billing work to a TaskExecutor and calls the billing service from there, the executor thread operates with an empty SecurityContext.
This asymmetry is documented in the Spring Security reference guide, but it is easy to miss because the two controller return types look symmetric to the developer and the synchronous test path (no executor, direct setResult() from the request thread) works without any configuration. The failure only manifests in production, where the TaskExecutor thread pool is active.
Failure mode 1: DeferredResult<T> + TaskExecutor — SecurityContextCallableProcessingInterceptor does not cover the executor thread — UUID.randomUUID() fallback generates UUID_A — @Retryable retry generates UUID_B
A developer building a billing controller uses DeferredResult<ResponseEntity<ChargeResult>> to avoid tying up Tomcat request threads during the Stripe HTTP call. They submit the billing work to a configured ThreadPoolTaskExecutor and call the billing service from within the submitted task:
// BillingController.java — UNSAFE: DeferredResult executor thread has no SecurityContext.
// SecurityContextCallableProcessingInterceptor only covers Callable<T> return types.
// The TaskExecutor thread has a null SecurityContextHolder — authentication is null.
// Defensive null-check in the billing service generates UUID.randomUUID() per invocation.
@RestController
@RequestMapping("/billing")
public class BillingController {
private final BillingService billingService;
private final TaskExecutor executor;
@PostMapping("/charge")
public DeferredResult<ResponseEntity<ChargeResult>> charge(
@RequestBody ChargeRequest req) {
DeferredResult<ResponseEntity<ChargeResult>> result =
new DeferredResult<>(10_000L); // 10s timeout
executor.execute(() -> {
try {
// SecurityContextHolder.getContext() returns an EMPTY context here.
// The executor thread was NOT set up by SecurityContextCallableProcessingInterceptor.
// That interceptor only fires for Callable<T> return types.
ChargeResult charge = billingService.createCharge(
req.getAmount(), req.getBillingPeriod());
result.setResult(ResponseEntity.ok(charge));
} catch (Exception ex) {
result.setErrorResult(ex);
}
});
return result;
}
}
The billing service tries to read the authenticated principal for the idempotency key:
// BillingService.java — UNSAFE: SecurityContextHolder.getContext().getAuthentication() is null
// on the TaskExecutor thread — null-check fallback generates UUID.randomUUID() per invocation.
@Service
public class BillingService {
private final StripeClient stripeClient;
@Retryable(maxAttempts = 3, value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2))
public ChargeResult createCharge(long amountCents, String billingPeriod) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String idempotencyKey;
if (auth != null && auth.isAuthenticated()) {
// This branch NEVER executes on the TaskExecutor thread.
// auth is always null because the executor thread has no SecurityContext.
idempotencyKey = sha256Hex(auth.getName() + ":" + billingPeriod).substring(0, 32);
} else {
// This branch ALWAYS executes on the TaskExecutor thread.
// UUID.randomUUID() generates UUID_A on the first @Retryable invocation
// and UUID_B on the retry invocation — both are different random values.
idempotencyKey = billingPeriod + "-" + UUID.randomUUID();
}
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return ChargeResult.from(charge);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
The failure sequence:
- Request arrives at Tomcat connector thread. Spring Security populates
SecurityContextHolderon the request thread with the authenticatedAuthenticationobject (e.g.,UsernamePasswordAuthenticationTokenwithname = "agent-007"). - Controller method runs on the request thread.
DeferredResultis created.executor.execute(Runnable)submits the billing task to aThreadPoolTaskExecutorworker thread. The request thread’sSecurityContextis not copied to the worker thread. The request thread is released to the Tomcat pool. - Worker thread starts executing.
billingService.createCharge()is called.SecurityContextHolder.getContext().getAuthentication()returnsnull— the worker thread has never had aSecurityContextset. - Null-check fallback fires.
idempotencyKey = "2026-Q4-aaaa-1111-..."(UUID_A). Stripe call is made. Stripe processes the charge and commitsch_A. - Stripe returns HTTP 503 (transient) after committing
ch_A.StripeExceptionis thrown and wrapped in aRuntimeException.@Retryablecatches the exception. @Retryablewaits the backoff delay (1 second) and retriescreateCharge()on the same worker thread. The worker thread still has noSecurityContext— the null-check fallback fires again.UUID.randomUUID()generatesUUID_B = "2026-Q4-bbbb-2222-...".- Stripe sees a key it has never seen. Stripe creates
ch_Bfor the same agent and billing period.
The double charge is invisible to the controller. deferredResult.setResult(ResponseEntity.ok(charge)) is called once (from the retry), and the HTTP response to the original request carries only the second charge’s result. The first charge ch_A is committed in Stripe with no corresponding record in the application database.
The subtle variant: assuming Spring Boot autoconfiguration handles DeferredResult context propagation
Spring Boot’s security autoconfiguration (SpringBootWebSecurityConfiguration and SecurityFilterChain via @EnableWebSecurity) wires up SecurityContextCallableProcessingInterceptor automatically. A developer who sees that their Callable<?> controllers work with SecurityContext out of the box, without any manual configuration, may reasonably assume that Spring Boot does the same for DeferredResult<?>. The assumption is wrong. The autoconfiguration wires the interceptor into WebMvcConfigurer.configureAsyncSupport(AsyncSupportConfigurer) only for the Callable<?> processing path — there is no equivalent hook for DeferredResult<?>.
The fix: DelegatingSecurityContextRunnable at task submission time
The fix requires capturing the SecurityContext on the request thread before task submission and restoring it on the worker thread for every execution including @Retryable retries. The correct place is the task submission point in the controller:
// BillingController.java — SAFE: SecurityContext captured at submission time and
// restored on the executor thread via DelegatingSecurityContextRunnable.
// Every invocation of billingService.createCharge() — including @Retryable retries —
// runs with the full SecurityContext from the original request thread.
@RestController
@RequestMapping("/billing")
public class BillingController {
private final BillingService billingService;
private final TaskExecutor executor;
@PostMapping("/charge")
public DeferredResult<ResponseEntity<ChargeResult>> charge(
@RequestBody ChargeRequest req) {
DeferredResult<ResponseEntity<ChargeResult>> result =
new DeferredResult<>(10_000L);
// Capture the SecurityContext on the request thread before releasing it.
// DelegatingSecurityContextRunnable restores the captured context on the
// executor thread before the Runnable.run() body executes and clears it after.
// This covers the initial @Retryable invocation AND all retry invocations —
// they all run inside the same Runnable.run() execution on the executor thread.
SecurityContext context = SecurityContextHolder.getContext();
executor.execute(new DelegatingSecurityContextRunnable(() -> {
try {
ChargeResult charge = billingService.createCharge(
req.getAmount(), req.getBillingPeriod());
result.setResult(ResponseEntity.ok(charge));
} catch (Exception ex) {
result.setErrorResult(ex);
}
}, context));
return result;
}
}
DelegatingSecurityContextRunnable takes two arguments: the wrapped Runnable and the SecurityContext to restore. When run() is called on the executor thread, it sets the provided SecurityContext on SecurityContextHolder, executes the wrapped runnable body (including all @Retryable retry invocations that happen synchronously inside the body), and then clears the context. Every invocation of billingService.createCharge() within the runnable — both the initial attempt and all retries — sees the same captured SecurityContext. The null-check fallback never fires, and the idempotency key is stable across all retry attempts.
The alternative is to configure the TaskExecutor itself to propagate context for every submitted task, which avoids requiring every controller to manually wrap its runnables:
// AsyncConfig.java — wrapping the executor so every submitted task
// automatically inherits the submitting thread's SecurityContext.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean
public ThreadPoolTaskExecutor billingExecutor() {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.setCorePoolSize(4);
pool.setMaxPoolSize(16);
pool.setQueueCapacity(100);
pool.setThreadNamePrefix("billing-");
pool.initialize();
return pool;
}
@Bean
public TaskExecutor delegatingBillingExecutor() {
// DelegatingSecurityContextExecutor captures the SecurityContext of the
// submitting thread at execute() time and sets it on the executor thread.
// Every task — including @Retryable retry continuations — sees the caller's context.
return new DelegatingSecurityContextExecutor(billingExecutor());
}
}
With DelegatingSecurityContextExecutor as the injected TaskExecutor in the controller, no per-call wrapping is needed. The executor automatically captures and restores the SecurityContext for every submitted Runnable.
Failure mode 2: SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) as attempted fix — InheritableThreadLocal propagates at thread creation time, not at task submission time — thread-pool threads created at startup have null context permanently
A developer who reads that SecurityContextHolder supports MODE_INHERITABLETHREADLOCAL to propagate security context to child threads may apply this as a global fix in the application’s startup configuration:
// SecurityConfig.java — ATTEMPTED FIX: MODE_INHERITABLETHREADLOCAL.
// Appears to solve the SecurityContext propagation problem for child threads.
// Does NOT fix it for thread-pool threads created before any request arrives.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@PostConstruct
public void configureSecurityContextStrategy() {
// Developer’s intent: make SecurityContext automatically available
// in any thread spawned from a request thread.
// This would work for new Thread(r).start() from a request thread.
// It does NOT work for pre-existing thread-pool worker threads.
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
}
The developer then removes the DelegatingSecurityContextRunnable wrapper, expecting MODE_INHERITABLETHREADLOCAL to handle propagation:
// BillingController.java — STILL UNSAFE after MODE_INHERITABLETHREADLOCAL applied.
// ThreadPoolTaskExecutor threads were created at startup — before any request.
// InheritableThreadLocal values on those threads are null (from application startup context).
// Worker thread has null SecurityContext → UUID fallback → UUID_A, UUID_B on retry.
@PostMapping("/charge")
public DeferredResult<ResponseEntity<ChargeResult>> charge(
@RequestBody ChargeRequest req) {
DeferredResult<ResponseEntity<ChargeResult>> result = new DeferredResult<>(10_000L);
executor.execute(() -> {
// Developer expects MODE_INHERITABLETHREADLOCAL to propagate SecurityContext here.
// It does not: this thread was created at application startup, not from the request thread.
try {
ChargeResult charge = billingService.createCharge(
req.getAmount(), req.getBillingPeriod());
result.setResult(ResponseEntity.ok(charge));
} catch (Exception ex) {
result.setErrorResult(ex);
}
});
return result;
}
The result: SecurityContextHolder.getContext().getAuthentication() is still null on the worker thread. The UUID fallback still fires. @Retryable still produces UUID_B.
Why MODE_INHERITABLETHREADLOCAL fails for thread pools: the thread creation time constraint
InheritableThreadLocal is a Java standard library class that propagates ThreadLocal values from a parent thread to a child thread at child thread creation time. The propagation happens inside Thread’s constructor: when new Thread(r) is called on a parent thread, the new child thread inherits a copy of all InheritableThreadLocal values that the parent thread currently holds. This propagation is a one-time snapshot at construction time — subsequent changes to the parent thread’s InheritableThreadLocal values do not affect the child thread, and changes to the child thread do not affect the parent.
Thread pools (such as Spring’s ThreadPoolTaskExecutor, which wraps Java’s ThreadPoolExecutor) create their worker threads once, typically at pool initialization time or on first use, not on every task submission. In a Spring Boot application, the ThreadPoolTaskExecutor bean’s initialize() method (called at application startup) creates the core threads. At application startup, no HTTP request is being processed and no user is authenticated — the current thread’s InheritableThreadLocal holds a null or empty SecurityContext. The created worker threads inherit this null context. From that point forward, those worker threads permanently have null InheritableThreadLocal SecurityContext values — no subsequent request will change them, because the threads already exist and InheritableThreadLocal propagation only happens at thread creation.
When a task is submitted to the pool on a request thread that has a valid SecurityContext, the pool worker that picks up the task runs it without inheriting the submitter’s SecurityContext. The task submission is not a thread creation event — it is just a Runnable being placed on a queue that an already-existing thread will pick up. InheritableThreadLocal has no role in task submission; it only operates during new Thread() construction.
The subtle variant: tests pass with MODE_INHERITABLETHREADLOCAL and SimpleAsyncTaskExecutor
Tests that configure a SimpleAsyncTaskExecutor instead of a ThreadPoolTaskExecutor may pass under MODE_INHERITABLETHREADLOCAL:
// TestAsyncConfig.java — test configuration that inadvertently hides the production bug.
@TestConfiguration
public class TestAsyncConfig {
@Bean
@Primary
public TaskExecutor testExecutor() {
// SimpleAsyncTaskExecutor creates a NEW thread per submitted task.
// The new thread is created from the current thread (the test thread)
// at execute() time — InheritableThreadLocal propagates the SecurityContext
// from the test thread to the new worker thread.
// This is how MODE_INHERITABLETHREADLOCAL is supposed to work —
// and it DOES work here because new Thread() is called per task.
return new SimpleAsyncTaskExecutor("billing-test-");
}
}
SimpleAsyncTaskExecutor.execute(Runnable) calls new Thread(r).start() per task (wrapping in CustomizableThreadFactory). The new thread is created from the current thread (the test thread, which has a SecurityContext set). InheritableThreadLocal propagates the SecurityContext to the new thread at construction time. The test’s billing service sees auth.getName() correctly, computes the same hash key on both the initial attempt and the retry, and the WireMock assertion of equal Idempotency-Key headers passes.
In production, SimpleAsyncTaskExecutor is replaced by a real ThreadPoolTaskExecutor. Worker threads were created at startup. InheritableThreadLocal does not propagate. Tests pass; production double-charges.
The correct fix: DelegatingSecurityContextExecutorService
The correct fix for thread pools is the delegation pattern, which copies the submitting thread’s SecurityContext at task submission time (not at thread creation time) and restores it on the worker thread before the task runs. Spring Security’s DelegatingSecurityContextExecutorService implements this for any ExecutorService:
// AsyncConfig.java — correct fix using DelegatingSecurityContextExecutorService.
// Captures SecurityContext at execute() / submit() call time (on the submitting thread).
// Restores the captured context on the worker thread before Runnable.run() executes.
// Works regardless of how the thread pool's threads were created or when.
@Configuration
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor billingExecutor() {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.setCorePoolSize(4);
pool.setMaxPoolSize(16);
pool.setQueueCapacity(100);
pool.setThreadNamePrefix("billing-");
pool.initialize();
return pool;
}
@Bean
public ExecutorService delegatingBillingExecutorService() {
// Wraps the underlying ExecutorService. On every execute() or submit() call:
// 1. Captures SecurityContextHolder.getContext() on the submitting thread.
// 2. Wraps the Runnable/Callable in a DelegatingSecurityContextRunnable/Callable.
// 3. When the worker thread picks up the task, DelegatingSecurityContextRunnable
// sets the captured context on SecurityContextHolder before run() proceeds.
// 4. After run() completes, the context is cleared from the worker thread.
return new DelegatingSecurityContextExecutorService(
billingExecutor().getThreadPoolExecutor());
}
}
The difference between MODE_INHERITABLETHREADLOCAL and DelegatingSecurityContextExecutorService is the timing of context capture: InheritableThreadLocal captures at thread creation time (fixed, happens once per thread, at startup); the delegation pattern captures at task submission time (dynamic, happens per task, from the submitting thread’s current context). For thread pools, only the delegation pattern provides correct per-request context isolation.
Failure mode 3: @Async and @Retryable on the same method — AOP advisor ordering causes @Retryable to see a Future return, never retry — developer splits the annotations — inner @Retryable service on executor thread has no SecurityContext — UUID fallback on every attempt
A developer who wants to make the billing service both non-blocking and retry-capable reaches for the combination of @Async and @Retryable. The initial attempt is to place both annotations on the same method:
// BillingService.java — FIRST ATTEMPT: @Async and @Retryable on the same method.
// This does not cause a double charge — it causes retry to silently not fire.
// @Retryable (order=1, outermost advisor) sees the CompletableFuture return from
// @Async (order=Integer.MAX_VALUE-2, innermost) — no StripeException reaches @Retryable.
@Service
public class BillingService {
@Async
@Retryable(maxAttempts = 3, value = {StripeException.class})
public CompletableFuture<ChargeResult> createChargeAsync(
long amountCents, String billingPeriod) {
// ... Stripe call
}
}
This appears to work in tests because the test’s mock Stripe client never throws, so there is nothing to retry. In production, when Stripe returns a transient 503, the method throws StripeException internally on the executor thread. Because @Async runs as the inner advisor, it submits the method body to the executor and returns a CompletableFuture to the outer @Retryable proxy. @Retryable intercepts at the proxy boundary and sees a successfully returned CompletableFuture — no exception at the proxy boundary — it does not retry. The StripeException inside the executor is wrapped as the future’s exception; the controller gets a failed future, but @Retryable never fires its retry logic.
Why @Retryable wraps @Async by default
Spring AOP advisor ordering determines which advisor wraps the bean method as the outermost proxy and which wraps as the innermost. Lower order number = higher priority = outermost execution wrapper. The advisors relevant here:
AsyncAnnotationAdvisor(registered by@EnableAsync): default order isOrdered.LOWEST_PRECEDENCE - 2— that is,Integer.MAX_VALUE - 2. A very high order number = very low priority = innermost advisor.AnnotationAwareRetryOperationsInterceptor(registered by@EnableRetry): default order is1. A very low order number = very high priority = outermost advisor.
With default ordering: @Retryable wraps @Async. Call sequence on the proxy: client calls method → @Retryable interceptor fires (outermost) → proceeds to @Async interceptor (innermost) → @Async submits method to executor and returns CompletableFuture immediately → @Retryable sees CompletableFuture returned without exception and returns it to the caller. Retry never fires on the async boundary. The actual StripeException lives inside the future.
Some developers address this by configuring @EnableAsync(order = 0) to make @Async run outermost. With @Async as the outer advisor and @Retryable as the inner: @Async submits work to executor → on executor thread, @Retryable intercepts the method body → @Retryable retries synchronously on the executor thread. Retry now fires — but SecurityContext is still not on the executor thread.
The developer’s practical workaround: split @Async and @Retryable across two beans
A common workaround for the @Async+@Retryable ordering problem is to split the annotations across two separate Spring beans: one bean with @Async calls another bean with @Retryable. Spring AOP self-invocation (calling a method on this from within the same bean) bypasses the proxy, so the two annotations must be on different beans to both fire through their respective AOP advisors:
// AsyncBillingFacade.java — @Async on the public facade method.
// Submits work to the executor and calls the @Retryable billing service bean.
// The @Async method becomes the async boundary; the @Retryable bean fires on the executor thread.
@Service
public class AsyncBillingFacade {
private final RetryableBillingService billingService;
@Async
public CompletableFuture<ChargeResult> submitCharge(
long amountCents, String billingPeriod) {
// This method body runs on the @Async executor thread.
// SecurityContext is NOT available on this thread (unless DelegatingSecurityContextAsyncTaskExecutor).
ChargeResult result = billingService.createCharge(amountCents, billingPeriod);
return CompletableFuture.completedFuture(result);
}
}
// RetryableBillingService.java — @Retryable on the billing method.
// Called from the AsyncBillingFacade's executor thread.
// SecurityContext is null on the executor thread → UUID fallback → UUID_B on retry.
@Service
public class RetryableBillingService {
private final StripeClient stripeClient;
@Retryable(maxAttempts = 3, value = {RuntimeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2))
public ChargeResult createCharge(long amountCents, String billingPeriod) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String idempotencyKey;
if (auth != null) {
// NEVER reached: @Async executor thread has no SecurityContext.
idempotencyKey = sha256Hex(auth.getName() + ":" + billingPeriod).substring(0, 32);
} else {
// ALWAYS reached: executor thread has null SecurityContext.
// UUID.randomUUID() generates UUID_A on attempt 1 and UUID_B on attempt 2.
idempotencyKey = billingPeriod + "-" + UUID.randomUUID();
}
try {
Charge charge = stripeClient.charges().create(
ChargeCreateParams.builder().setAmount(amountCents).setCurrency("usd").build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return ChargeResult.from(charge);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
}
The failure sequence with this two-bean split:
- Controller calls
asyncBillingFacade.submitCharge().@Asyncadvisor intercepts, submits the facade method body to theThreadPoolTaskExecutor. ReturnsCompletableFutureimmediately. Tomcat request thread is released. - Executor thread picks up the task. No
SecurityContexton the executor thread.billingService.createCharge()is called via Spring proxy (different bean, so proxy fires). @Retryableadvisor interceptscreateCharge(). Method body executes on the executor thread.SecurityContextHolder.getContext().getAuthentication()is null. Fallback fires:idempotencyKey = "2026-Q4-aaaa-1111-..."(UUID_A). Stripe call fires. Stripe commitsch_A.- Stripe returns 503.
StripeExceptionis thrown, wrapped asRuntimeException.@Retryablecatches it (still on the executor thread), waits 1 second, retriescreateCharge(). - Method body executes again on the same executor thread.
SecurityContextstill null. Fallback fires again:idempotencyKey = "2026-Q4-bbbb-2222-..."(UUID_B, new random). Stripe createsch_B.
The fix: DelegatingSecurityContextAsyncTaskExecutor
The fix wraps the ThreadPoolTaskExecutor used by @EnableAsync with DelegatingSecurityContextAsyncTaskExecutor, which is Spring Security’s adapter for AsyncTaskExecutors (the interface used by Spring’s @Async infrastructure):
// AsyncConfig.java — SAFE: DelegatingSecurityContextAsyncTaskExecutor wraps the
// ThreadPoolTaskExecutor. Every @Async submission captures the submitting thread's
// SecurityContext and restores it on the executor thread before the task body runs.
// This covers the AsyncBillingFacade's @Async method body AND the RetryableBillingService's
// @Retryable retries (which run synchronously inside the @Async task body on the same
// executor thread, with SecurityContext restored by the delegation wrapper).
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.setCorePoolSize(4);
pool.setMaxPoolSize(16);
pool.setQueueCapacity(100);
pool.setThreadNamePrefix("async-billing-");
pool.initialize();
// DelegatingSecurityContextAsyncTaskExecutor implements AsyncTaskExecutor.
// Spring's @Async infrastructure calls submit(Callable) or execute(Runnable)
// on this executor. At submit/execute time, the current thread's SecurityContext
// is captured. When the worker thread picks up the task, the captured context
// is set on SecurityContextHolder before the task body runs, then cleared after.
return new DelegatingSecurityContextAsyncTaskExecutor(pool);
}
}
With DelegatingSecurityContextAsyncTaskExecutor configured, the call flow becomes:
- Controller calls
asyncBillingFacade.submitCharge()on the request thread (SecurityContext available). @AsynccallsDelegatingSecurityContextAsyncTaskExecutor.submit()— captures the request thread’sSecurityContextat submission time — wraps the callable in aDelegatingSecurityContextCallable.- Executor thread picks up the task.
DelegatingSecurityContextCallable.call()sets the capturedSecurityContextonSecurityContextHolderbefore calling the actual task body. asyncBillingFacade.submitCharge()body runs.SecurityContextHolder.getContext().getAuthentication()is not null.billingService.createCharge()is called.@Retryablefires.createCharge()body runs.auth.getName()returns"agent-007".idempotencyKey = sha256(agent-007:2026-Q4)[:32]. Same on every retry attempt.- Stripe sees the same idempotency key on the retry and returns
ch_A’s result — noch_B.
The key insight: DelegatingSecurityContextAsyncTaskExecutor captures context at submission time (on the calling thread) rather than at execution time (on the worker thread). @Retryable retries happen synchronously within the same task body execution on the same worker thread, which has the captured SecurityContext active for the entire duration of the task body.
Integration tests that catch all three failure modes
A single WireMock integration test can verify idempotency key stability across all three async paths. The test configuration must:
- Use a real
ThreadPoolTaskExecutor(notSimpleAsyncTaskExecutor), so thatMODE_INHERITABLETHREADLOCALfailures are detectable. - Set a mock
SecurityContexton the test thread before the controller call, so the billing service has a principal to derive a stable key from. - Configure WireMock to fail the first Stripe attempt with 503 and succeed on the second, so the
@Retryableretry actually fires. - Capture the
Idempotency-Keyheader from both WireMock requests and assert they are equal.
// BillingAsyncIntegrationTest.java — verifies idempotency key stability across all
// async paths: DeferredResult executor, MODE_INHERITABLETHREADLOCAL, @Async+@Retryable.
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
class BillingAsyncIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void deferredResultCharge_retryUsesStableIdempotencyKey() {
// Stub: fail first attempt, succeed on second.
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("RETRY")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":{\"type\":\"api_error\"}}"))
.willSetStateTo("RETRIED"));
stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("RETRY")
.whenScenarioStateIs("RETRIED")
.willReturn(aResponse().withStatus(200).withBody("""
{\"id\":\"ch_test_001\",\"amount\":9900,\"currency\":\"usd\",\"paid\":true}
""")));
// Set SecurityContext so the billing service has a real principal.
// This simulates what a JWT filter or basic-auth filter would do on a real request.
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken("agent-007", null,
List.of(new SimpleGrantedAuthority("ROLE_AGENT")));
SecurityContextHolder.getContext().setAuthentication(auth);
try {
ResponseEntity<String> response = restTemplate.postForEntity(
"/billing/charge",
Map.of("amount", 9900, "billingPeriod", "2026-Q4"),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
} finally {
SecurityContextHolder.clearContext();
}
// Give the async executor time to complete (DeferredResult path).
await().atMost(5, SECONDS).until(() ->
findAll(postRequestedFor(urlEqualTo("/v1/charges"))).size() == 2);
List<LoggedRequest> requests = findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(requests).hasSize(2);
String keyAttempt1 = requests.get(0).getHeader("Idempotency-Key");
String keyAttempt2 = requests.get(1).getHeader("Idempotency-Key");
// Both attempts must use the same key — any difference is a double-charge bug.
assertThat(keyAttempt1)
.isNotBlank()
.isEqualTo(keyAttempt2);
}
}
To cover the MODE_INHERITABLETHREADLOCAL failure mode specifically, use a @TestConfiguration that explicitly creates the ThreadPoolTaskExecutor via new ThreadPoolTaskExecutor() and calls initialize() before any test executes — ensuring the pool threads are created before SecurityContextHolder.setAuthentication() in the test. If the idempotency key assertion fails, the fix is missing; if it passes, the delegation wrapper is correctly propagating context to pre-existing pool threads.
For the @Async+@Retryable split-bean path, the same test applies against the async facade endpoint. The critical configuration to verify: the AsyncTaskExecutor returned by AsyncConfigurer.getAsyncExecutor() must be a DelegatingSecurityContextAsyncTaskExecutor wrapping a ThreadPoolTaskExecutor. The test verifies that the idempotency key is equal on both WireMock requests; it does not distinguish which executor path was taken, so a single test covers all three modes if the application is configured correctly.
Summary: the async thread boundary always needs explicit context delegation in Spring MVC
The root cause behind all three failure modes is a single invariant: SecurityContextHolder with its default MODE_THREADLOCAL strategy stores authentication per-thread. Any async boundary — DeferredResult task submission, @Async executor submission, or any other hand-off to a pooled thread — does not automatically carry the request thread’s SecurityContext to the worker thread. This is not a bug in Spring Security; it is an explicit design choice that prevents accidental context leakage between requests. The developer is responsible for propagating context across async boundaries.
| Async pattern | SecurityContext propagation | Correct fix |
|---|---|---|
Callable<T> controller return |
Automatic via SecurityContextCallableProcessingInterceptor |
No action needed |
DeferredResult<T> + manual executor |
None — developer must propagate | DelegatingSecurityContextRunnable at submission, or DelegatingSecurityContextExecutor wrapping the executor |
MODE_INHERITABLETHREADLOCAL on thread pool |
None — pool threads exist before requests | DelegatingSecurityContextExecutorService instead of InheritableThreadLocal |
@Async on @EnableAsync executor |
None by default | DelegatingSecurityContextAsyncTaskExecutor as the AsyncConfigurer’s executor |
The three patterns in this post are all Spring WebMVC async variants of a more general principle: idempotency keys must be computed before the async boundary from stable inputs available on the calling thread, or the async thread must be explicitly equipped with the calling thread’s SecurityContext via a delegation wrapper. Both approaches fix the problem; the delegation wrapper is preferable when multiple billing code paths share the same executor, since it eliminates per-call wrapping and covers future code that may be added to the executor-submitted task.
The posts in this series cover the same root cause across different Spring execution models: Spring Boot synchronous @Retryable, Spring WebFlux reactive retryWhen(), Spring Cloud Gateway GlobalFilter and RetryGatewayFilterFactory, Spring Security OAuth2 JWT jti claim and ReactiveSecurityContextHolder, and here, Spring WebMVC async DeferredResult and @Async. The pattern is consistent: wherever a retry or async boundary re-executes code that generates a UUID, a new idempotency key reaches Stripe and a new charge is committed. The fix is always the same structure: compute a deterministic key from stable, request-invariant inputs before the retry and async scope, capture it as a closure variable or method parameter, and ensure it is the same value on every attempt.
At Keybrake, we address the class of problem at the infrastructure layer rather than at the code layer: a scoped vault key issued per agent run carries a per-vendor spend cap that prevents a stuck retry loop from charging more than the intended amount even if the idempotency key does change. The vault key’s daily USD cap on the Stripe vendor means that a double charge that exceeds the cap is blocked at the proxy layer, not silently committed to Stripe. That is the right backstop when you cannot control every async execution context in every service that might call your billing endpoint.
Cap what your agent can charge — per vendor, per run
Keybrake issues scoped proxy keys with per-vendor spend limits. Point your agent at proxy.keybrake.com/stripe/v1/charges instead of Stripe directly. We enforce the cap, log every call, and give you a one-click kill switch. Join the waitlist to get early access.