Go · Golang · AI agents · API key management
Go AI agent API key management: vault keys via context and http.Client
Go AI agent tool backends typically initialize vendor API clients at package or application startup — stripe.Key = os.Getenv("STRIPE_SECRET_KEY") — then serve all incoming goroutines from the same shared client. This is idiomatic Go for conventional request handlers. For autonomous agents it's a liability: every concurrent agent goroutine shares the same Stripe key with no per-goroutine spend cap, no endpoint allowlist, and no way to revoke one runaway agent's access without killing all active requests. Vault keys fix this through Go's built-in context.Context propagation, passing a per-goroutine scoped credential down the call stack without modifying global state.
TL;DR
Define a typed context key and a helper that issues a vault key from Keybrake then stores it with context.WithValue(ctx, vaultKeyCtxKey{}, token). Wrap your HTTP handler in middleware that calls this helper and passes the enriched context to the next handler. Tool functions that call Stripe read the vault key from context using ctx.Value(vaultKeyCtxKey{}) and call proxy.keybrake.com with that token instead of the real Stripe key. Use defer revokeVaultKey(ctx, id) in the middleware for cleanup.
The Go AI agent tool backend pattern
The most common Go pattern for AI agent tool backends initializes the vendor client once and uses it everywhere:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
stripe "github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/paymentintent"
)
func init() {
// Package-level init — shared across ALL goroutines
stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
}
func chargeHandler(w http.ResponseWriter, r *http.Request) {
// This uses the package-level stripe.Key for every request
params := &stripe.PaymentIntentParams{
Amount: stripe.Int64(5000),
Currency: stripe.String(string(stripe.CurrencyUSD)),
}
pi, err := paymentintent.New(params)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(pi)
}
The stripe-go library's package-level stripe.Key is a global variable shared across all goroutines. Even if you initialize a per-handler stripe.Client, you typically pass the same key to all instances. Two agents running simultaneously in the same process share credentials at the Stripe API level — Stripe cannot distinguish their spend.
Context-propagated vault keys in Go
Go's context.Context is designed for exactly this problem: passing request-scoped values down the call stack without changing function signatures. Define a typed key to avoid collisions:
package vault
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
// Unexported type prevents key collisions from other packages
type contextKey struct{}
type VaultKey struct {
ID string
Token string
}
// Issue creates a new vault key and stores it in the returned context
func Issue(ctx context.Context, label, vendor string, dailyCapUSD int, allowedEndpoints []string, ttl string) (context.Context, error) {
body, _ := json.Marshal(map[string]any{
"label": label,
"vendor": vendor,
"daily_usd_cap": dailyCapUSD,
"allowed_endpoints": allowedEndpoints,
"expires_in": ttl,
})
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.keybrake.com/v1/keys", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("KEYBRAKE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ctx, fmt.Errorf("keybrake issue: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 201 {
return ctx, fmt.Errorf("keybrake issue: status %d", resp.StatusCode)
}
var vk VaultKey
if err := json.NewDecoder(resp.Body).Decode(&vk); err != nil {
return ctx, err
}
return context.WithValue(ctx, contextKey{}, vk), nil
}
// FromContext retrieves the vault key from context
func FromContext(ctx context.Context) (VaultKey, bool) {
vk, ok := ctx.Value(contextKey{}).(VaultKey)
return vk, ok
}
// Revoke deletes the vault key; best called via defer
func Revoke(ctx context.Context, id string) {
req, _ := http.NewRequestWithContext(ctx, "DELETE", "https://api.keybrake.com/v1/keys/"+id, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KEYBRAKE_TOKEN"))
http.DefaultClient.Do(req) // non-critical: TTL is the safety net
}
Middleware integration
Wrap your HTTP mux with middleware that issues the vault key before the handler and revokes it after:
package main
import (
"log"
"net/http"
"yourapp/vault"
)
func VaultMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only apply to agent tool routes
if !strings.HasPrefix(r.URL.Path, "/tools/") {
next.ServeHTTP(w, r)
return
}
userID := r.Header.Get("X-User-ID")
runID := r.Header.Get("X-Agent-Run-ID")
label := fmt.Sprintf("go-%s-%s", userID, runID)
ctx, err := vault.Issue(r.Context(), label, "stripe", 500,
[]string{"/v1/payment_intents", "/v1/payment_intents/*"}, "5m")
if err != nil {
// Fail open with a warning; vault key is defense-in-depth
log.Printf("WARN: vault key issuance failed: %v", err)
next.ServeHTTP(w, r)
return
}
vk, _ := vault.FromContext(ctx)
defer vault.Revoke(context.Background(), vk.ID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/tools/charge", chargeHandler)
// Wrap mux with vault middleware
http.ListenAndServe(":8080", VaultMiddleware(mux))
}
Tool function reading vault key from context
Tool functions read the vault key from context and call the Keybrake proxy instead of Stripe directly:
func chargeHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
vk, ok := vault.FromContext(ctx)
if !ok {
// Fallback: vault key unavailable (issuance failed)
http.Error(w, "vault key not available", 503)
return
}
// Call Keybrake proxy with vault token instead of stripe.Key
reqBody, _ := json.Marshal(map[string]any{
"amount": 5000,
"currency": "usd",
})
proxyReq, _ := http.NewRequestWithContext(ctx, "POST",
"https://proxy.keybrake.com/stripe/v1/payment_intents", bytes.NewReader(reqBody))
proxyReq.Header.Set("Authorization", "Bearer "+vk.Token)
proxyReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(proxyReq)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer resp.Body.Close()
// 429 with cap_exhausted = spend cap hit, not a transient error
if resp.StatusCode == 429 {
var errBody struct {
Code string `json:"code"`
}
json.NewDecoder(resp.Body).Decode(&errBody)
if errBody.Code == "cap_exhausted" {
http.Error(w, "spend_cap_exceeded", http.StatusPaymentRequired)
return
}
}
w.Header().Set("Content-Type", "application/json")
io.Copy(w, resp.Body)
}
Temporal and workflow patterns in Go
Go AI agent backends frequently use Temporal for durable workflows. Vault keys integrate at the activity level — each activity is the right granularity for a single vault key:
// Temporal activity: one vault key per activity execution
func StripeChargeActivity(ctx context.Context, params ChargeParams) (string, error) {
info := activity.GetInfo(ctx)
label := fmt.Sprintf("temporal-%s-%s", info.WorkflowExecution.ID, info.ActivityID)
ctx, err := vault.Issue(ctx, label, "stripe", params.MaxSpendUSD,
[]string{"/v1/payment_intents"}, "10m")
if err != nil {
return "", fmt.Errorf("vault key: %w", err)
}
vk, _ := vault.FromContext(ctx)
defer vault.Revoke(context.Background(), vk.ID)
// Use vk.Token to call proxy.keybrake.com/stripe/...
return createPaymentIntent(ctx, vk.Token, params.Amount)
}
For Temporal workflows that span multiple activities, don't share a single vault key across the workflow — issue one per activity. This matches Temporal's activity retry model: if an activity retries, the revoked key from the previous attempt doesn't block the retry.
| Pattern | Vault key scope | TTL recommendation |
|---|---|---|
| HTTP handler (single Stripe call) | Per-request via middleware | 5 minutes (longer than any single HTTP call) |
| HTTP handler (multi-step workflow) | Per-request, single key for all steps | SLA + 20% buffer |
| Temporal activity | Per-activity execution | Activity schedule_to_close_timeout + 20% |
| Goroutine worker pool | Per goroutine task, passed via context | Task timeout + 20% |
Related questions
Does this work with the official stripe-go library or only raw HTTP?
The vault key pattern works with raw HTTP calls to proxy.keybrake.com, not with the stripe-go SDK directly — the SDK always uses its own HTTP client configured with stripe.Key. For Go AI agent backends, the cleanest approach is to skip the stripe-go SDK entirely for proxied calls and use net/http directly. If you need stripe-go's type-safe response structs, you can instantiate a stripe.Client with a custom http.Client that sets Host: proxy.keybrake.com and Authorization: Bearer <vault-token> headers via a custom http.RoundTripper. This requires overriding the backend URL in the stripe-go client — check stripe.Client.B in the stripe-go source for the custom backend pattern.
How do I handle vault key issuance failures without blocking all agent requests?
Vault key issuance should be treated as defense-in-depth, not a hard blocker by default. The recommended pattern is to fail open (log a warning, proceed without the vault key) for the initial rollout phase, then fail closed (return 503) once you've validated that issuance latency is acceptable in production. Use Go's context.WithTimeout to bound the issuance call: issueCtx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond); defer cancel(). If issuance times out, the middleware falls through without a vault key. Add a metric increment on fallback so you can observe the fallback rate in production.
What's the correct context key type to avoid collisions?
Always use an unexported struct type as the context key, not a string or integer. Using type contextKey struct{} in an unexported position means only code in your package can produce that key — other packages can't accidentally overwrite it with a string like "vault_key" that happens to match. This is Go's recommended pattern for context keys (documented in the context package godoc). If you expose the vault key to downstream packages, export a FromContext(ctx) (VaultKey, bool) function but keep the key type unexported — callers get the value without being able to set it directly.
Further reading
- Temporal AI agent API key — vault keys in Temporal workflow and activity patterns, including retry-safe scoping and the workflow-vs-activity key granularity decision.
- AI agent API key lifecycle — issuance, enforcement, expiration, and revocation phases mapped to Go's context cancellation and goroutine lifecycle.
- AI agent idempotency — idempotency key patterns for Go HTTP handlers calling Stripe, including Temporal saga compensation patterns.
- AI agent error handling — handling spend cap 429s, retry logic with exponential backoff, and error propagation patterns for Go agent backends.
- Multi-tenant isolation — per-tenant vault key issuance patterns for Go SaaS backends serving multiple organizations.