Spring Security OAuth2 Resource Server and Stripe Integration: How JWT jti Claims, ReactiveSecurityContextHolder Flat-Maps, and CompletableFuture Thread Propagation Generate New Idempotency Keys on Retry
Spring Security OAuth2 resource servers introduce three Stripe idempotency failure modes that are invisible in plain Spring Boot services and absent from the Spring WebFlux and Spring Cloud Gateway patterns covered in prior posts. The failure modes all stem from the same root cause: the Authentication object that an OAuth2 resource server builds from a Bearer token has per-token-issuance properties — most critically, the jti (JWT ID) claim, which is a UUID assigned uniquely to each token issued by the authorization server. Three distinct paths convert that per-token property into a Stripe double charge: first, a developer who builds an idempotency key from jti + billingRef gets UUID_B when the agent refreshes its access token after a network timeout where Stripe already committed ch_A; second, a developer in a WebFlux billing service who places UUID.randomUUID() inside a ReactiveSecurityContextHolder.getContext().flatMap() callback gets UUID_B on every Reactor retry re-subscription; third, a developer whose billing service uses CompletableFuture.supplyAsync() to call Stripe asynchronously loses the calling thread’s SecurityContext inside the supplier — a defensive null-check generates UUID.randomUUID() on the null path — Stripe creates ch_B on the retry that hits that path.
Background: the JWT token model and why jti is not a stable user identifier
When a Spring application is configured as an OAuth2 resource server with JWT support, every incoming HTTP request carries a Bearer token. Spring Security decodes the token using JwtDecoder, validates the signature and expiry, and builds a JwtAuthenticationToken that wraps a Jwt object containing all claims from the token. The Jwt object is accessible via @AuthenticationPrincipal Jwt jwt on a controller method or via SecurityContextHolder.getContext().getAuthentication().getPrincipal() from a service.
The JWT standard (RFC 7519) defines several registered claims. Most developers are familiar with sub (subject — the user or agent identifier, stable across all tokens issued for that principal) and exp (expiration time). A less-well-known claim is jti (JWT ID): a case-sensitive string that provides a unique identifier for the JWT. RFC 7519 requires that jti be assigned in a manner that ensures there is a negligible probability of the same value being accidentally assigned to a different data object. In practice, every authorization server generates a fresh UUID for jti on every token issuance — including every token refresh. jti is stable for the lifetime of one token, then permanently discarded.
Spring Security exposes jti via Jwt.getId(), mirroring the standard field name. This method returns the value of the jti claim as a String. A developer looking for a per-user stable identifier from the JWT often reaches for jwt.getId() without realizing it returns the per-token UUID, not the per-user subject. The sub claim is exposed via Jwt.getSubject() or Jwt.getClaimAsString("sub"). These two accessors look superficially similar but represent fundamentally different things: getId() changes on every token refresh, getSubject() stays constant for the same principal across all tokens.
For Stripe idempotency keys, this distinction is decisive. An idempotency key must be stable across all attempts for the same logical billing operation. Building the key from jti produces a key that is stable within one token’s lifetime but changes the moment that token is replaced by a refresh — exactly the timing at which an agent is most likely to retry.
Failure mode 1: JWT jti as idempotency key component — agent token refresh between retry attempts changes jti — Stripe creates ch_B for the same logical billing period
A developer building a billing endpoint for an autonomous agent secures it with Spring Security OAuth2 resource server validation. The agent authenticates with a short-lived access token and calls POST /billing/charge. The developer needs an idempotency key that ties the charge to both the agent and the billing period. They reach for jwt.getId(), which looks like a stable unique identifier:
// BillingController.java — UNSAFE: jti changes on every token refresh.
// Agent refreshes token after network timeout → new jti → new idempotency key → ch_B.
@RestController
@RequestMapping("/billing")
public class BillingController {
private final StripeChargeService chargeService;
@PostMapping("/charge")
public ResponseEntity<ChargeResult> charge(
@AuthenticationPrincipal Jwt jwt,
@RequestBody ChargeRequest req) {
// UNSAFE: jwt.getId() returns the jti claim — a UUID unique per token issuance.
// Looks like a stable per-agent identifier but changes every time the agent
// refreshes its access token. An agent that refreshes after a timeout where
// Stripe already committed ch_A will submit a different idempotency key on retry.
String jti = jwt.getId();
String idempotencyKey = jti + ":" + req.getBillingPeriod();
return ResponseEntity.ok(chargeService.createCharge(req.getAmount(), idempotencyKey));
}
}
The billing service passes the key down to Stripe:
// StripeChargeService.java — receives idempotency key from caller.
// Key is stable per invocation, but the caller constructs it from jti.
@Service
public class StripeChargeService {
private final StripeClient stripeClient;
@Retryable(maxAttempts = 3, value = {StripeException.class},
backoff = @Backoff(delay = 1000, multiplier = 2))
public ChargeResult createCharge(long amountCents, String idempotencyKey) {
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(amountCents)
.setCurrency("usd")
.setSource("tok_visa")
.build();
Charge charge = stripeClient.charges().create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return ChargeResult.from(charge);
}
}
The @Retryable in StripeChargeService retries the same idempotencyKey string on all attempts — the key is stable within this method. The bug lives one layer up, in how the caller constructs the key from jti. The failure sequence is:
- Agent holds access token T1 (claims:
sub=agent-007,jti=aaaa-1111-...,exp=T+300s). Agent callsPOST /billing/chargewithbillingPeriod=2026-Q4. - Controller extracts
jtifrom T1:idempotencyKey = "aaaa-1111-...:2026-Q4". Charge service forwards to Stripe. Stripe processes the charge and commitsch_A. - Network latency spike: the HTTP response from the billing service to the agent is delayed past the agent’s configured timeout (15 seconds). The agent marks the call as failed and does not receive
ch_A. - The agent’s OAuth2 client detects that T1 is approaching expiry and refreshes to T2 (claims:
sub=agent-007,jti=bbbb-2222-...).jtiis a fresh UUID. - Agent retries
POST /billing/chargewith T2 and the samebillingPeriod=2026-Q4. - Controller extracts
jtifrom T2:idempotencyKey = "bbbb-2222-...:2026-Q4". This key has never been seen by Stripe. Stripe createsch_Bfor the same agent and billing period.
The double charge is invisible at the controller layer: both requests were authenticated, authorized, and processed without error. The only evidence is two line items in the Stripe dashboard with different charge IDs but identical amounts and metadata.
The subtle variant: token relay through a gateway
The failure is even harder to spot when the billing service sits behind a Spring Cloud Gateway that uses TokenRelayGatewayFilterFactory to forward the Bearer token downstream. The gateway’s OAuth2 client may refresh the token proactively (before expiry) when it detects the remaining lifetime is below a threshold. The gateway then relays the refreshed T2 to the billing service on a retry that the gateway itself initiated after a downstream 503. The billing service sees two distinct requests with different jti values — both well-formed, both authorized — and constructs two distinct idempotency keys for what the gateway considers one logical operation.
The fix: use sub + a stable billing reference as the idempotency key
The sub claim is the correct stable per-principal identifier. It does not change when the token is refreshed. Combined with a billing reference that is stable for the duration of the logical operation (a billing period, an invoice ID, an idempotent request ID supplied by the agent in the request body), it produces a key that Stripe will deduplicate correctly regardless of how many tokens the agent cycles through:
// BillingController.java — SAFE: sub claim is stable across token refreshes.
@PostMapping("/charge")
public ResponseEntity<ChargeResult> charge(
@AuthenticationPrincipal Jwt jwt,
@RequestBody ChargeRequest req) {
// sub is the stable per-principal identifier. It does not change on token refresh.
// jti is per-token — never use jti, iat, or exp in an idempotency key.
String agentId = jwt.getSubject(); // Jwt.getSubject() → "sub" claim
// Content-hash over stable inputs: agentId + billingPeriod + service qualifier.
// SHA-256 then truncate to 32 hex chars (well within Stripe's 255-char limit).
String rawKey = agentId + ":" + req.getBillingPeriod() + ":charge";
String idempotencyKey = sha256Hex(rawKey).substring(0, 32);
return ResponseEntity.ok(chargeService.createCharge(req.getAmount(), idempotencyKey));
}
private static String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
The content-hash approach has an additional benefit: even if the billing period string is long or contains special characters, the 32-character hex output is always well within Stripe’s idempotency key length limit and safe to log. The key is deterministic — re-computing it at any point in the request lifecycle produces the same value as long as agentId and billingPeriod are unchanged.
If the agent supplies its own idempotency reference in the request body (a common pattern where agents generate a stable requestId before the first attempt), prefer that over a server-side hash: idempotencyKey = agentId + ":" + req.getRequestId(). The agent’s stable requestId survives token refreshes and carries the agent’s own retry intent.
Failure mode 2: ReactiveSecurityContextHolder.getContext().flatMap() with UUID.randomUUID() — Mono.retryWhen() re-subscribes the upstream chain — UUID re-evaluates per retry attempt
In a WebFlux-based billing service secured with Spring Security OAuth2, the SecurityContext is not stored in a thread-local variable but propagated through the Reactor Context. The standard way to access the authenticated principal from a reactive pipeline is ReactiveSecurityContextHolder.getContext(), which returns a Mono<SecurityContext> that resolves to the context stored in the current Reactor subscription’s context map. This is lazy: it evaluates per subscription, not at assembly time.
A developer building a reactive billing service constructs the idempotency key inside the flatMap callback that processes the resolved SecurityContext:
// ReactiveBillingService.java — UNSAFE: UUID.randomUUID() inside flatMap.
// ReactiveSecurityContextHolder.getContext() is lazy-per-subscription.
// Mono.retryWhen() re-subscribes the upstream chain on retry.
// flatMap callback re-executes on every subscription → UUID_B on first retry.
@Service
public class ReactiveBillingService {
private final WebClient stripeWebClient;
public Mono<ChargeResult> createCharge(long amountCents, String billingPeriod) {
return ReactiveSecurityContextHolder.getContext()
.flatMap(ctx -> {
Authentication auth = ctx.getAuthentication();
String agentId = auth.getName(); // stable across retries — same SecurityContext
// UNSAFE: UUID.randomUUID() is called each time this flatMap lambda
// executes. The lambda executes once per Reactor subscription.
// retryWhen() re-subscribes on retry → this lambda fires again → UUID_B.
String idempotencyKey = agentId + "-" + UUID.randomUUID();
return stripeWebClient.post()
.uri("/v1/charges")
.header("Authorization", "Bearer " + getStripeKey())
.header("Idempotency-Key", idempotencyKey)
.bodyValue(buildChargeBody(amountCents))
.retrieve()
.bodyToMono(ChargeResult.class);
})
.retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(2))
.filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable));
}
}
The execution trace on retry:
retryWhen()subscribes to the upstreamMonochain (everything fromgetContext()onwards). Reactor subscribes togetContext(), which resolves to the currentSecurityContext.flatMapcallback executes:agentId = "agent-007",idempotencyKey = "agent-007-aaaa-1111-...". Stripe WebClient call fires. Stripe processes the request and commitsch_A.- Stripe returns HTTP 503 (transient). The
WebClientResponseException.ServiceUnavailablepropagates out ofbodyToMono. retryWhen()catches the exception and re-subscribes to the upstream chain. This is a new Reactor subscription.getContext()resolves again (still the sameSecurityContext, because the Reactor context map is carried through the retry re-subscription).flatMapcallback executes again.agentId = "agent-007"(stable).UUID.randomUUID()evaluates again.idempotencyKey = "agent-007-bbbb-2222-..."(new UUID).- Stripe creates
ch_Bfor a key it has never seen.
The agentId from auth.getName() is correctly stable — the SecurityContext is propagated through the Reactor retry re-subscription via the context map. The UUID is the sole source of instability.
Why Mono.defer() does not fix this
A developer who recognizes that Reactor operators can re-evaluate per subscription sometimes wraps the entire chain in Mono.defer(), expecting that defer’s lazy-per-subscription semantics will give each subscription a fresh but independently stable execution:
// STILL UNSAFE: Mono.defer() is lazy-per-subscription, not lazy-with-cache.
// The defer factory executes on every subscription including retry re-subscriptions.
// UUID.randomUUID() inside the defer factory fires again → UUID_B.
public Mono<ChargeResult> createCharge(long amountCents, String billingPeriod) {
return Mono.defer(() ->
ReactiveSecurityContextHolder.getContext()
.flatMap(ctx -> {
String key = ctx.getAuthentication().getName() + "-" + UUID.randomUUID();
return stripeWebClient.post()
// ...
.header("Idempotency-Key", key)
.retrieve()
.bodyToMono(ChargeResult.class);
})
).retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(2)).filter(...));
}
Mono.defer() takes a Supplier<Mono<T>>. The supplier is called on every subscription to the outer Mono. retryWhen() re-subscribes on retry → the defer supplier fires → UUID.randomUUID() inside the supplier evaluates again → UUID_B. Mono.defer() does not cache the result of the first subscription and replay it; it calls the factory fresh each time. It is useful for making cold publishers, not for ensuring a value is computed once and reused across subscriptions.
Why Mono.cache() is also wrong
The fix is NOT to add .cache() to the getContext() call. Caching the SecurityContext across subscriptions would cause a second subscriber (a different request on a different Reactor pipeline) to receive the first request’s authentication. ReactiveSecurityContextHolder.getContext() is intentionally not cached for precisely this reason.
The fix: compute the idempotency key synchronously before the reactive chain
The correct approach separates the key computation from the reactive subscription lifecycle. The key is computed once, synchronously, from a known-stable context, and captured as a final local variable that the reactive chain reads but never re-generates:
// ReactiveBillingService.java — SAFE: key computed before the reactive chain.
// The reactive chain subscribes to the context Mono once to extract the principal,
// then the key computation runs synchronously outside any retry scope.
public Mono<ChargeResult> createCharge(long amountCents, String billingPeriod) {
// Compute the key in a one-shot Mono that resolves before the retry scope starts.
// The Mono.flatMap here runs once; the outer .retryWhen() only re-tries the
// Stripe call, not the key derivation.
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> {
// .map() is fine here — synchronous, cheap, one execution.
String agentId = ctx.getAuthentication().getName(); // sub-claim name
return sha256Hex(agentId + ":" + billingPeriod + ":charge").substring(0, 32);
})
.flatMap(idempotencyKey ->
// idempotencyKey is a stable String captured by the flatMap closure.
// retryWhen() re-subscribes inside this flatMap, but idempotencyKey
// is evaluated before the retryWhen() scope — it is the same String on
// every retry invocation of the inner Mono.
stripeWebClient.post()
.uri("/v1/charges")
.header("Authorization", "Bearer " + getStripeKey())
.header("Idempotency-Key", idempotencyKey)
.bodyValue(buildChargeBody(amountCents))
.retrieve()
.bodyToMono(ChargeResult.class)
.retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(2))
.filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable))
);
}
The structural key: retryWhen() is placed inside the flatMap, on the Stripe WebClient call alone, after idempotencyKey has been computed and captured as a closure variable. The retry re-subscribes only to stripeWebClient.post()...retrieve().bodyToMono(), not to ReactiveSecurityContextHolder.getContext(). The closure captures idempotencyKey as a final effectively-final String; every retry invocation of the inner Mono reads the same captured value. This is the same structural principle applied in the Reactor Netty post: scope the retry to the network call only, not to the key derivation.
Failure mode 3: CompletableFuture.supplyAsync() billing implementation — ForkJoinPool threads do not inherit SecurityContext — SecurityContextHolder.getContext() returns null inside the supplier — defensive fallback generates UUID.randomUUID() — double charge on retry
Not all Spring Security OAuth2 billing services are purely reactive. Many use imperative Spring MVC controllers with CompletableFuture for asynchronous Stripe calls — the controller stays non-blocking while the billing service performs I/O. The typical pattern:
// ImperativeBillingService.java — UNSAFE: CompletableFuture.supplyAsync() loses SecurityContext.
// ForkJoinPool.commonPool() threads do not inherit the calling thread's ThreadLocal state.
// SecurityContextHolder uses ThreadLocal by default (MODE_THREADLOCAL).
// SecurityContextHolder.getContext() inside the supplier returns an empty context.
// Defensive null-check generates UUID.randomUUID() → ch_B on @Retryable retry.
@Service
public class ImperativeBillingService {
private final StripeClient stripeClient;
@Retryable(maxAttempts = 3, value = {StripeException.class},
backoff = @Backoff(delay = 500, multiplier = 2))
public CompletableFuture<ChargeResult> createChargeAsync(
long amountCents, String billingPeriod) {
return CompletableFuture.supplyAsync(() -> {
// UNSAFE: ForkJoinPool thread has no ThreadLocal SecurityContext.
// SecurityContextHolder.MODE_THREADLOCAL (default) stores context per-thread.
// The calling request thread set the SecurityContext, but supplyAsync()
// submits the supplier to a ForkJoinPool worker thread — different thread,
// empty context.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Developer added this null-check defensively — seems safe.
// But on every invocation via the ForkJoinPool, auth IS null.
// The null branch generates a new UUID.randomUUID() each time.
String agentId;
String idempotencyKey;
if (auth != null) {
agentId = auth.getName();
idempotencyKey = sha256Hex(agentId + ":" + billingPeriod).substring(0, 32);
} else {
// This branch always executes on ForkJoinPool threads.
// UUID.randomUUID() generates UUID_B on every invocation.
idempotencyKey = billingPeriod + "-" + UUID.randomUUID();
}
try {
Charge charge = stripeClient.charges().create(
buildParams(amountCents),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return ChargeResult.from(charge);
} catch (StripeException e) {
throw new RuntimeException(e);
}
});
}
}
The @Retryable annotation on createChargeAsync intercepts the method at the Spring AOP proxy level, on the calling thread. When @Retryable catches the RuntimeException thrown from the supplier (via CompletableFuture.join() or .get() in the controller), it calls createChargeAsync again. Each call submits a new supplier to ForkJoinPool.commonPool(). Each ForkJoinPool thread starts with an empty SecurityContextHolder (the default MODE_THREADLOCAL strategy uses a ThreadLocal<SecurityContext> — ForkJoinPool threads are not related to the request thread and do not inherit its ThreadLocal values).
The null-check fallback ensures the method doesn’t throw a NullPointerException, but it creates a different idempotency key on every invocation. The failure sequence:
createChargeAsyncis called from the request thread.@Retryableproxy intercepts but does not retry yet.- Supplier submitted to
ForkJoinPool. ForkJoinPool worker thread:auth = null→ null branch →idempotencyKey = "2026-Q4-aaaa-1111-...". Stripe call fires with this key. Stripe processes and commitsch_A. - The Stripe call times out at the HTTP level (15 seconds).
StripeException(connection timeout) is thrown fromstripeClient.charges().create(). The supplier wraps it inRuntimeExceptionand completes theCompletableFutureexceptionally. - The caller calls
future.join()→ theCompletionExceptionpropagates. The@Retryableproxy catches the wrappedStripeExceptionand retries. - Second invocation of
createChargeAsync. New supplier submitted toForkJoinPool. New worker thread:auth = null→ null branch →idempotencyKey = "2026-Q4-bbbb-2222-...". Stripe createsch_B.
Why the null-check passes unnoticed in testing
Unit tests for this service typically either mock SecurityContextHolder.getContext() or set it up on the test thread via SecurityContextHolder.setContext(). In both cases, auth is non-null in the test, so the happy path executes and the test observes the content-hash key. The null path is never exercised by standard unit or integration tests unless the test explicitly verifies behavior on a thread without a security context. End-to-end tests that call the actual REST endpoint via MockMvc or TestRestTemplate also execute the supplier synchronously within the test thread’s security context in some configurations. The bug is production-only: the ForkJoinPool worker thread is the trigger, and production traffic on a real JVM with the default ForkJoinPool.commonPool() is where it manifests.
Why the null path is always hit with the default executor
CompletableFuture.supplyAsync(Supplier) (with no explicit executor argument) uses ForkJoinPool.commonPool(). The common pool manages a fixed set of threads sized to available processors minus one. These threads are not created by the request-handling framework and are not associated with any particular incoming request. SecurityContextHolder.MODE_THREADLOCAL (the default and the most common production configuration) stores the SecurityContext in a ThreadLocal<SecurityContext> keyed to the thread that processed the incoming request — typically a Tomcat or Undertow worker thread. The ForkJoinPool thread that runs the supplier is a completely different thread; its ThreadLocal map has no entry for SecurityContextHolder’s storage key. SecurityContextHolder.getContext() on the ForkJoinPool thread returns an empty SecurityContext (a new SecurityContextImpl with no Authentication), and .getAuthentication() on that empty context returns null.
Fix option 1: pass the idempotency key as a method parameter
The simplest fix is to compute the idempotency key on the calling thread, before the CompletableFuture is submitted, and pass it as an explicit parameter to the billing service. The @Retryable-annotated method receives the pre-computed key and passes it unchanged to Stripe on every retry attempt:
// BillingController.java — compute key on request thread, pass to service.
@PostMapping("/charge")
public ResponseEntity<ChargeResult> charge(
@AuthenticationPrincipal Jwt jwt,
@RequestBody ChargeRequest req) {
// Key computed here, on the request thread, where the SecurityContext is valid.
String agentId = jwt.getSubject(); // sub claim — stable across token refreshes
String idempotencyKey = sha256Hex(agentId + ":" + req.getBillingPeriod()).substring(0, 32);
// Pass pre-computed key to service — service never touches SecurityContextHolder.
CompletableFuture<ChargeResult> future = billingService.createChargeAsync(
req.getAmount(), idempotencyKey);
return ResponseEntity.ok(future.join());
}
// ImperativeBillingService.java — key received as parameter, no SecurityContext access.
@Retryable(maxAttempts = 3, value = {StripeException.class})
public CompletableFuture<ChargeResult> createChargeAsync(long amount, String idempotencyKey) {
return CompletableFuture.supplyAsync(() -> {
try {
Charge charge = stripeClient.charges().create(
buildParams(amount),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
return ChargeResult.from(charge);
} catch (StripeException e) {
throw new RuntimeException(e);
}
});
}
The key is now a stable closure variable captured by the supplier. Every retry invocation of createChargeAsync passes the same idempotencyKey string computed on the request thread. The ForkJoinPool thread never needs to access SecurityContextHolder.
Fix option 2: DelegatingSecurityContextExecutorService
If the billing service must access SecurityContextHolder inside the async supplier for other reasons (e.g., permission checks on the billing operation), use DelegatingSecurityContextExecutorService to wrap the executor used by supplyAsync(). This wrapper captures the SecurityContext at task submission time and sets it on the worker thread before the supplier runs:
// BillingConfig.java
@Configuration
public class BillingConfig {
@Bean
public Executor billingTaskExecutor() {
// Wrap a standard thread pool executor with the security context delegator.
// DelegatingSecurityContextExecutorService captures SecurityContext at
// task-submission time and sets it on the worker thread before execution.
ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
return new DelegatingSecurityContextExecutorService(pool);
}
}
// ImperativeBillingService.java — using the delegating executor.
@Service
public class ImperativeBillingService {
@Autowired
@Qualifier("billingTaskExecutor")
private Executor billingExecutor;
@Retryable(maxAttempts = 3, value = {StripeException.class})
public CompletableFuture<ChargeResult> createChargeAsync(long amount, String billingPeriod) {
return CompletableFuture.supplyAsync(() -> {
// SecurityContext from the submission thread is available here.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String agentId = auth.getName(); // Now works correctly on the worker thread.
String key = sha256Hex(agentId + ":" + billingPeriod).substring(0, 32);
try {
Charge charge = stripeClient.charges().create(buildParams(amount),
RequestOptions.builder().setIdempotencyKey(key).build());
return ChargeResult.from(charge);
} catch (StripeException e) {
throw new RuntimeException(e);
}
}, billingExecutor);
}
}
Note that DelegatingSecurityContextExecutorService captures the SecurityContext once at submission time. On a @Retryable retry, the retry happens on the calling (request) thread, which does have the SecurityContext. The submission of the new CompletableFuture.supplyAsync() on the retry path captures the same SecurityContext again. The key computed inside the supplier produces the same value on every retry because it derives from the same agentId and billingPeriod. Do not add UUID.randomUUID() to the key inside the supplier even with this fix.
Integration test: detecting all three failure modes
Each failure mode requires a slightly different test setup. The common element is WireMock configured to fail on the first attempt and succeed on the second, combined with a header capture assertion that requires the Idempotency-Key value to be identical across all requests to Stripe.
Test for failure mode 1 (jti-based key with token refresh)
// BillingControllerJtiTest.java
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
@WireMockTest
class BillingControllerJtiTest {
@Autowired MockMvc mockMvc;
@Test
void chargeIsIdempotentAcrossTokenRefresh(WireMockRuntimeInfo wm) throws Exception {
// Two JWTs: same sub, different jti — simulates token refresh.
String sub = "agent-007";
String jti1 = UUID.randomUUID().toString();
String jti2 = UUID.randomUUID().toString(); // different jti — new token issuance
JwtClaimsSet claims1 = JwtClaimsSet.builder()
.subject(sub).id(jti1).expiresAt(Instant.now().plusSeconds(300)).build();
JwtClaimsSet claims2 = JwtClaimsSet.builder()
.subject(sub).id(jti2).expiresAt(Instant.now().plusSeconds(300)).build();
String token1 = mintJwt(claims1);
String token2 = mintJwt(claims2);
// Call billing endpoint with token1, then token2, same billing period.
mockMvc.perform(post("/billing/charge")
.header("Authorization", "Bearer " + token1)
.contentType(APPLICATION_JSON)
.content("{\"amount\":5000,\"billingPeriod\":\"2026-Q4\"}"))
.andExpect(status().isOk());
mockMvc.perform(post("/billing/charge")
.header("Authorization", "Bearer " + token2)
.contentType(APPLICATION_JSON)
.content("{\"amount\":5000,\"billingPeriod\":\"2026-Q4\"}"))
.andExpect(status().isOk());
// Both requests should send the SAME Idempotency-Key to Stripe.
// A jti-based key fails this assertion: key1 = jti1:2026-Q4, key2 = jti2:2026-Q4.
// A sub-based content-hash key passes: sha256(agent-007:2026-Q4) is the same for both.
List<LoggedRequest> stripeRequests = WireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2);
assertThat(stripeRequests.get(0).getHeader("Idempotency-Key"))
.isEqualTo(stripeRequests.get(1).getHeader("Idempotency-Key"));
}
}
Test for failure mode 2 (ReactiveSecurityContextHolder + retryWhen)
// ReactiveBillingServiceTest.java
@SpringBootTest
@WireMockTest
class ReactiveBillingServiceTest {
@Autowired ReactiveBillingService billingService;
@Test
void reactiveChargeSendsStableIdempotencyKeyOnRetry(WireMockRuntimeInfo wm) {
// Stripe fails attempt 1 (503), succeeds attempt 2.
stubFor(post("/v1/charges").inScenario("reactive-retry")
.whenScenarioStateIs(STARTED)
.willReturn(serviceUnavailable())
.willSetStateTo("retry"));
stubFor(post("/v1/charges").inScenario("reactive-retry")
.whenScenarioStateIs("retry")
.willReturn(ok().withBody("{\"id\":\"ch_test\",\"status\":\"succeeded\"}")));
// Execute billing through the reactive service.
// The SecurityContext is populated via the test's @WithMockUser or
// WebTestClient's mutateWith(mockJwt()) for reactive tests.
StepVerifier.create(
billingService.createCharge(5000L, "2026-Q4")
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(
buildJwtAuth("agent-007")))
)
.expectNextCount(1)
.verifyComplete();
// Capture both requests sent to WireMock and assert key stability.
List<LoggedRequest> stripeRequests = WireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2);
String keyOnAttempt1 = stripeRequests.get(0).getHeader("Idempotency-Key");
String keyOnAttempt2 = stripeRequests.get(1).getHeader("Idempotency-Key");
// FAILS if UUID.randomUUID() is inside flatMap: UUID_A ≠ UUID_B.
// PASSES with pre-computed content-hash key.
assertThat(keyOnAttempt1)
.as("Idempotency-Key must be identical on all retry attempts")
.isEqualTo(keyOnAttempt2);
}
}
Test for failure mode 3 (CompletableFuture + SecurityContext thread propagation)
// ImperativeBillingServiceTest.java
@SpringBootTest
@WireMockTest
class ImperativeBillingServiceTest {
@Autowired ImperativeBillingService billingService;
@Test
void asyncChargeSendsStableIdempotencyKeyOnRetry(WireMockRuntimeInfo wm) {
// Fail first Stripe attempt, succeed second.
stubFor(post("/v1/charges").inScenario("async-retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503).withBody("{\"error\":\"unavailable\"}"))
.willSetStateTo("retry"));
stubFor(post("/v1/charges").inScenario("async-retry")
.whenScenarioStateIs("retry")
.willReturn(ok().withBody("{\"id\":\"ch_test\",\"status\":\"succeeded\"}")));
// Set SecurityContext on the calling (test) thread.
Jwt jwt = Jwt.withTokenValue("token")
.subject("agent-007")
.claim("jti", UUID.randomUUID().toString())
.expiresAt(Instant.now().plusSeconds(300))
.issuedAt(Instant.now())
.build();
SecurityContextHolder.getContext().setAuthentication(
new JwtAuthenticationToken(jwt, List.of(), jwt.getSubject()));
try {
billingService.createChargeAsync(5000L, "2026-Q4").join();
} finally {
SecurityContextHolder.clearContext();
}
List<LoggedRequest> stripeRequests = WireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
assertThat(stripeRequests).hasSize(2);
// FAILS with null-check UUID fallback: key1 = "2026-Q4-UUID_A", key2 = "2026-Q4-UUID_B".
// PASSES with pre-computed key passed as parameter or DelegatingSecurityContextExecutor.
assertThat(stripeRequests.get(0).getHeader("Idempotency-Key"))
.isEqualTo(stripeRequests.get(1).getHeader("Idempotency-Key"));
}
}
The assertion is the same in all three tests: capture every Idempotency-Key header that arrived at the WireMock server representing Stripe and assert that all values are equal. This single assertion catches UUID re-evaluation at any layer, regardless of whether the source is a JWT claim change, a reactive subscription boundary, or a thread-pool context loss.
The pattern across all three failure modes
A useful summary of what to never include in a Stripe idempotency key from a Spring Security OAuth2 service:
| What to avoid | Why it changes | Safe alternative |
|---|---|---|
jwt.getId() (jti claim) |
New UUID per token issuance; changes on every refresh | jwt.getSubject() (sub claim) |
UUID.randomUUID() inside a Reactor flatMap or defer |
Evaluates per Reactor subscription; retryWhen() re-subscribes |
Pre-compute before flatMap; scope retryWhen() to the network call only |
UUID.randomUUID() in a null-check fallback inside CompletableFuture.supplyAsync() |
ForkJoinPool threads have no inherited SecurityContext; null branch fires every time |
Pass pre-computed key as parameter; or use DelegatingSecurityContextExecutorService |
jwt.getIssuedAt() (iat claim) |
Per-token timestamp; changes on every issuance | Billing period string or caller-supplied requestId |
jwt.getExpiresAt() (exp claim) |
Per-token expiry; changes on every issuance | Content-hash of stable billing inputs |
The structural principle is identical to the one described in the Spring Boot, Spring WebFlux, and Spring Cloud Gateway posts: compute the idempotency key from request-invariant inputs, synchronously, before any boundary that could change the execution context. In an OAuth2 resource server, the additional invariant to respect is that the Authentication object’s per-token claims (jti, iat, exp) are not stable across the agent’s token lifetime. Only the sub claim and the claims that your authorization server explicitly treats as stable per-principal identifiers are safe to include.
Keybrake as a defense layer for OAuth2-secured billing
A correctly implemented idempotency key ensures that duplicate requests for the same logical operation hit Stripe’s deduplication window. But even a correct client-side key strategy can be undermined by network layers outside the application’s control: a load balancer that retries on upstream failure, a service mesh that performs automatic retry on 503, or a gateway that relays refreshed tokens to the billing service without the billing service’s knowledge. Keybrake sits between your application and Stripe and enforces idempotency at the proxy layer, independently of what the client sends — you define a policy that maps agent principal (sub claim) + billing period to a spend cap, and Keybrake rejects duplicate charges for the same logical operation regardless of what idempotency key the upstream client generates. The vault key issued to the agent also carries an allowlist of permitted Stripe endpoints (/v1/charges, /v1/payment_intents) and a per-day spend cap, so a stuck agent loop cannot spend beyond its authorized budget even if it cycles through tokens rapidly. See the pricing page for plan details.
Stop runaway agents before they hit Stripe
Keybrake proxies your agent’s Stripe calls, enforces per-day spend caps, and gives you one-click revoke — so a stuck retry loop stops at the cap, not at your bank account.