Java · Spring Boot · AI agents · API key management
Spring Boot AI agent API key management: vault keys via HandlerInterceptor and request scope
Spring Boot AI agent tool backends define @Service classes that inject the Stripe API key via @Value("${stripe.secret.key}"). Spring's default bean scope is singleton — one instance per application context, shared across all concurrent HTTP threads. This works for conventional REST APIs where per-request isolation isn't needed. For AI agents, it means every concurrent agent request shares the same Stripe credential with no per-request spend cap and no way to revoke one runaway agent's access. Vault keys issued in a HandlerInterceptor and stored in a request-scoped bean give each agent request its own scoped credential without changing controllers or existing service interfaces.
TL;DR
Create a @RequestScope @Component VaultKeyHolder bean that stores the vault key token and ID for the current request. Create a HandlerInterceptor that issues the vault key in preHandle() and stores it in the VaultKeyHolder via injection, and revokes it in afterCompletion(). Register the interceptor for /api/agent/** paths only. Service classes that need the vault key inject VaultKeyHolder and call proxy.keybrake.com instead of Stripe directly.
The Spring Boot AI agent tool backend pattern
A standard Spring Boot agent tool backend uses property injection and singleton services:
// Standard Spring Boot Stripe service — singleton, shared across all threads
@Service
public class StripeChargeService {
@Value("${stripe.secret.key}")
private String stripeSecretKey; // Same value injected for every request
public PaymentIntent createPaymentIntent(long amount, String customerId) throws StripeException {
Stripe.apiKey = stripeSecretKey; // Global SDK configuration
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(amount)
.setCurrency("usd")
.setCustomer(customerId)
.build();
return PaymentIntent.create(params);
}
}
@RestController
@RequestMapping("/api/agent")
public class AgentToolController {
@Autowired
private StripeChargeService stripeChargeService;
@PostMapping("/charge")
public ResponseEntity<Map<String, Object>> charge(@RequestBody ChargeRequest req)
throws StripeException {
PaymentIntent pi = stripeChargeService.createPaymentIntent(req.getAmount(), req.getCustomerId());
return ResponseEntity.ok(Map.of("id", pi.getId(), "status", pi.getStatus()));
}
}
The problem: Stripe.apiKey is a class-level static field in the Stripe Java SDK. All threads share it. Concurrent agent requests for different users all use the same key — there's no isolation at the credential level.
Request-scoped VaultKeyHolder bean
Spring's @RequestScope creates a new bean instance for each HTTP request, automatically cleaned up when the request completes. It's the correct primitive for per-request state:
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.web.context.WebApplicationContext;
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
proxyMode = ScopedProxyMode.TARGET_CLASS)
public class VaultKeyHolder {
private String token;
private String keyId;
public String getToken() { return token; }
public String getKeyId() { return keyId; }
public void setKey(String token, String keyId) {
this.token = token;
this.keyId = keyId;
}
public boolean hasKey() { return token != null; }
}
The proxyMode = ScopedProxyMode.TARGET_CLASS is required so that singleton beans (like services and controllers) can inject VaultKeyHolder — Spring creates a proxy that delegates to the current request's instance at runtime.
HandlerInterceptor: issue and revoke
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.net.http.*;
import java.net.URI;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.logging.Logger;
@Component
public class VaultKeyInterceptor implements HandlerInterceptor {
private static final Logger log = Logger.getLogger(VaultKeyInterceptor.class.getName());
private static final String KEYBRAKE_API = "https://api.keybrake.com/v1/keys";
@Value("${keybrake.token}")
private String keybrakeToken;
@Autowired
private VaultKeyHolder vaultKeyHolder;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String userId = request.getHeader("X-User-ID") != null
? request.getHeader("X-User-ID") : "anon";
String runId = request.getHeader("X-Agent-Run-ID") != null
? request.getHeader("X-Agent-Run-ID") : java.util.UUID.randomUUID().toString().substring(0, 8);
String label = "springboot-" + userId + "-" + runId;
try {
String body = objectMapper.writeValueAsString(Map.of(
"label", label,
"vendor", "stripe",
"daily_usd_cap", 500,
"allowed_endpoints", new String[]{"/v1/payment_intents", "/v1/payment_intents/*"},
"expires_in", "5m"
));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(KEYBRAKE_API))
.header("Authorization", "Bearer " + keybrakeToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(5))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 201) {
Map<?, ?> keyData = objectMapper.readValue(resp.body(), Map.class);
vaultKeyHolder.setKey(
(String) keyData.get("token"),
(String) keyData.get("id")
);
} else {
log.warning("Vault key issuance failed: " + resp.statusCode());
}
} catch (Exception e) {
log.warning("Vault key issuance error: " + e.getMessage());
}
return true; // Proceed even if vault key issuance fails
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
String keyId = vaultKeyHolder.getKeyId();
if (keyId == null) return;
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(KEYBRAKE_API + "/" + keyId))
.header("Authorization", "Bearer " + keybrakeToken)
.DELETE()
.timeout(Duration.ofSeconds(3))
.build();
httpClient.send(req, HttpResponse.BodyHandlers.discarding());
} catch (Exception e) {
log.warning("Vault key revocation error: " + e.getMessage());
}
}
}
Registering the interceptor for agent routes only
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private VaultKeyInterceptor vaultKeyInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(vaultKeyInterceptor)
.addPathPatterns("/api/agent/**") // Only agent tool routes
.excludePathPatterns("/api/agent/health"); // Exclude health check
}
}
Calling the proxy from a proxied service
@Service
public class ProxiedStripeService {
@Autowired
private VaultKeyHolder vaultKeyHolder;
private static final String PROXY_BASE = "https://proxy.keybrake.com/stripe";
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
public Map<String, Object> createPaymentIntent(long amount, String customerId)
throws Exception {
if (!vaultKeyHolder.hasKey()) {
throw new IllegalStateException("No vault key available for this request");
}
String body = objectMapper.writeValueAsString(Map.of(
"amount", amount,
"currency", "usd",
"customer", customerId
));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(PROXY_BASE + "/v1/payment_intents"))
.header("Authorization", "Bearer " + vaultKeyHolder.getToken())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 429) {
Map<?, ?> errBody = objectMapper.readValue(resp.body(), Map.class);
if ("cap_exhausted".equals(errBody.get("code"))) {
throw new SpendCapExceededException("Agent spend cap reached");
}
}
if (resp.statusCode() != 200) {
throw new RuntimeException("Stripe proxy error: " + resp.statusCode());
}
return objectMapper.readValue(resp.body(), Map.class);
}
}
class SpendCapExceededException extends RuntimeException {
SpendCapExceededException(String msg) { super(msg); }
}
Spring AI integration
Spring AI (the official Spring integration for LLM-based applications) uses a @Tool annotation pattern for agent tools that maps cleanly to the vault key interceptor approach:
@Component
public class StripeToolFunction {
@Autowired
private ProxiedStripeService proxiedStripeService;
// Spring AI tool function — called by the LLM agent
@Tool(description = "Create a Stripe payment intent for the specified amount in cents")
public String createPaymentIntent(
@ToolParam(description = "Amount in USD cents") int amount,
@ToolParam(description = "Stripe customer ID") String customerId) {
try {
Map<String, Object> result = proxiedStripeService.createPaymentIntent(amount, customerId);
return "Payment intent created: " + result.get("id") + " (status: " + result.get("status") + ")";
} catch (SpendCapExceededException e) {
return "ERROR: Agent spend cap reached. Cannot create additional payment intents this session.";
} catch (Exception e) {
return "ERROR: " + e.getMessage();
}
}
}
The Spring AI tool function is invoked per-agent-turn. Since the vault key interceptor runs once per HTTP request (the AI chat request), all tool calls within one agent turn share the same vault key. This is the correct scope: one agent turn = one budget unit.
| Spring bean scope | Instance created | Shared across | Right for vault key? |
|---|---|---|---|
@Singleton (default) |
Once at startup | All requests, all threads | No |
@RequestScope |
Once per HTTP request | One request, one agent turn | Yes — correct granularity |
@SessionScope |
Once per HTTP session | All requests in one browser session | No — too broad for spend caps |
@Prototype |
Every injection point | Not shared — new instance each time | No — can't share state across injections |
Related questions
Can I use this pattern with Spring WebFlux (reactive) instead of Spring MVC?
Spring WebFlux doesn't use HandlerInterceptor — it uses WebFilter. The equivalent in WebFlux is a WebFilter that issues the vault key, stores it in the reactive Context (not in a @RequestScope bean, which doesn't work in reactive), and revokes it in a doFinally operator on the filter chain. Retrieve the vault key in service methods via Mono.deferContextual(ctx -> ...). Spring WebFlux's request scope is handled differently — use ServerWebExchange.getAttributes() as the per-request store instead of a @RequestScope bean, since reactive execution doesn't use thread-local state.
Does the ScopedProxy on VaultKeyHolder add performance overhead?
The ScopedProxyMode.TARGET_CLASS on VaultKeyHolder generates a CGLIB proxy class that delegates method calls to the current request's bean instance. The overhead is a few nanoseconds per method call — negligible compared to the vault key issuance network round-trip (~50ms). For very high-throughput agent endpoints (1,000+ RPS), the CGLIB overhead won't show up in profiling. The network call to api.keybrake.com dominates by 4-5 orders of magnitude. Pre-warm the HttpClient's connection pool by reusing it across the interceptor's lifetime (make it a field, not a local variable).
How do I test controllers that use vault key injection?
In Spring MVC tests, @RequestScope beans are recreated per test. Use @WebMvcTest with a @MockBean VaultKeyHolder to stub the vault key holder, and verify that the interceptor calls the Keybrake API and populates the holder. For integration tests, use @SpringBootTest with a WireMock stub for api.keybrake.com — stub the POST to return a test vault key, then verify that your service calls proxy.keybrake.com with the Bearer token from the stub response. This gives you full end-to-end coverage without live Keybrake API calls in CI.
Further reading
- NestJS AI agent API key — the TypeScript/NestJS equivalent using interceptors and REQUEST-scoped providers — same pattern, different framework idioms.
- Django AI agent API key — the Python/Django equivalent using middleware and thread-local storage.
- AI agent API key lifecycle — issuance, enforcement, expiration, and revocation phases mapped to Spring's bean lifecycle and interceptor model.
- AI agent compliance — using Keybrake audit logs for SOC 2 compliance in Spring Boot enterprise applications.
- Multi-tenant isolation — per-tenant vault key scoping in Spring Boot multi-tenant applications using tenant-aware interceptors.