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.

PatternVault key scopeTTL 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%

Get early access

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