Ruby · Rails · AI agents · API key management
Ruby on Rails AI agent API key management: vault keys for agent tool actions
Ruby on Rails AI agent tool backends configure vendor API clients in initializers that run once at boot: Stripe.api_key = ENV['STRIPE_SECRET_KEY'] in config/initializers/stripe.rb. Puma serves all concurrent requests from a thread pool sharing this singleton configuration. When multiple users run AI agents simultaneously, every Stripe call from every concurrent thread uses the same key — no per-request spend cap, no per-user allowlist, no way to kill one runaway agent without revoking access for all. Vault keys issued per-request via a before_action hook, stored in thread-local Current attributes, and revoked in an after_action, give each agent action its own scoped credential without changing how Stripe is called in service objects.
TL;DR
Create a VaultKeyService that issues and revokes credentials via the Keybrake API. In ApplicationController, add a before_action :issue_vault_key that stores the token in Current.vault_key (a thread-safe ActiveSupport::CurrentAttributes subclass) and an after_action :revoke_vault_key. Tool service objects read Current.vault_key and call proxy.keybrake.com instead of Stripe directly. Override only in the controllers that serve agent tool routes — not the entire application.
The Rails AI agent tool backend pattern
A standard Rails agent tool backend initializes Stripe once and calls it from service objects:
# config/initializers/stripe.rb
Stripe.api_key = ENV["STRIPE_SECRET_KEY"] # Set once at boot, shared globally
# app/services/stripe_charge_service.rb
class StripeChargeService
def call(amount:, customer_id:)
# Uses Stripe.api_key — the same key for every concurrent request
Stripe::PaymentIntent.create(
amount: amount,
currency: "usd",
customer: customer_id
)
end
end
# app/controllers/agent_tools_controller.rb
class AgentToolsController < ApplicationController
def charge
result = StripeChargeService.new.call(
amount: params[:amount].to_i,
customer_id: params[:customer_id]
)
render json: result
end
end
This is idiomatic Rails. The problem for AI agents is that Stripe.api_key is a class-level attribute — a global shared by every Puma thread. Two users running agents concurrently share the same credential. Stripe sees all their traffic as coming from the same key and cannot enforce per-user spend limits at the key level.
CurrentAttributes for thread-safe vault key storage
Rails provides ActiveSupport::CurrentAttributes as an explicitly thread-local store reset between requests — the right primitive for per-request vault keys:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :vault_key_token # The Bearer token for proxy calls
attribute :vault_key_id # The Keybrake key ID for revocation
attribute :user_id # Track which user's agent this is
end
Current attributes are automatically reset between requests by Rails — no cleanup needed. They're thread-local, so concurrent Puma threads each have their own values.
VaultKeyService: issue and revoke
# app/services/vault_key_service.rb
require "net/http"
require "json"
class VaultKeyService
KEYBRAKE_API = "https://api.keybrake.com/v1/keys"
def self.issue(label:, vendor:, daily_usd_cap:, allowed_endpoints:, expires_in: "5m")
uri = URI(KEYBRAKE_API)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 2
http.read_timeout = 5
req = Net::HTTP::Post.new(uri.path)
req["Authorization"] = "Bearer #{ENV["KEYBRAKE_TOKEN"]}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
label: label,
vendor: vendor,
daily_usd_cap: daily_usd_cap,
allowed_endpoints: allowed_endpoints,
expires_in: expires_in
})
resp = http.request(req)
raise "Keybrake issue failed: #{resp.code}" unless resp.is_a?(Net::HTTPCreated)
JSON.parse(resp.body, symbolize_names: true)
rescue => e
Rails.logger.warn "VaultKey issuance failed: #{e.message}"
nil
end
def self.revoke(key_id)
return if key_id.nil?
uri = URI("#{KEYBRAKE_API}/#{key_id}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Delete.new(uri.path)
req["Authorization"] = "Bearer #{ENV["KEYBRAKE_TOKEN"]}"
http.request(req)
rescue => e
Rails.logger.warn "VaultKey revocation failed: #{e.message}"
end
end
before_action and after_action hooks
# app/controllers/agent_tools_controller.rb
class AgentToolsController < ApplicationController
before_action :issue_vault_key
after_action :revoke_vault_key
def charge
if Current.vault_key_token.nil?
render json: { error: "vault_key_unavailable" }, status: :service_unavailable
return
end
result = ProxiedStripeService.new.create_payment_intent(
amount: params[:amount].to_i,
customer_id: params[:customer_id]
)
render json: result
rescue ProxiedStripeService::SpendCapExceeded
render json: { error: "spend_cap_exceeded" }, status: :payment_required
end
private
def issue_vault_key
user_id = current_user&.id.to_s || "anon"
run_id = request.headers["X-Agent-Run-ID"] || SecureRandom.hex(8)
label = "rails-#{user_id}-#{run_id}"
result = VaultKeyService.issue(
label: label,
vendor: "stripe",
daily_usd_cap: 500,
allowed_endpoints: ["/v1/payment_intents", "/v1/payment_intents/*"],
expires_in: "5m"
)
return if result.nil?
Current.vault_key_token = result[:token]
Current.vault_key_id = result[:id]
Current.user_id = user_id
end
def revoke_vault_key
VaultKeyService.revoke(Current.vault_key_id)
end
end
ProxiedStripeService: calling through Keybrake
# app/services/proxied_stripe_service.rb
class ProxiedStripeService
SpendCapExceeded = Class.new(StandardError)
PROXY_BASE = "https://proxy.keybrake.com/stripe"
def create_payment_intent(amount:, customer_id:)
uri = URI("#{PROXY_BASE}/v1/payment_intents")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Post.new(uri.path)
req["Authorization"] = "Bearer #{Current.vault_key_token}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({ amount: amount, currency: "usd", customer: customer_id })
resp = http.request(req)
if resp.code == "429"
body = JSON.parse(resp.body)
raise SpendCapExceeded if body["code"] == "cap_exhausted"
end
raise "Stripe proxy error: #{resp.code}" unless resp.is_a?(Net::HTTPOK)
JSON.parse(resp.body)
end
end
Sidekiq background jobs
If your AI agent pipeline runs agent steps as Sidekiq jobs, Current attributes don't carry across job boundaries — each job starts with a fresh Current. Issue vault keys at the top of the perform method:
# app/jobs/agent_stripe_charge_job.rb
class AgentStripeChargeJob < ApplicationJob
queue_as :agent_tools
def perform(user_id:, amount:, customer_id:, run_id:)
label = "sidekiq-#{user_id}-#{run_id}"
result = VaultKeyService.issue(
label: label,
vendor: "stripe",
daily_usd_cap: 200,
allowed_endpoints: ["/v1/payment_intents"],
expires_in: "10m"
)
return Rails.logger.warn "Vault key issuance failed for job #{job_id}" if result.nil?
Current.vault_key_token = result[:token]
Current.vault_key_id = result[:id]
begin
ProxiedStripeService.new.create_payment_intent(
amount: amount,
customer_id: customer_id
)
ensure
VaultKeyService.revoke(Current.vault_key_id)
end
end
end
Use ensure instead of after_action for Sidekiq — the job framework doesn't have controller lifecycle hooks, but Ruby's ensure block is the equivalent guarantee.
| Rails context | Vault key storage | Issuance point | Revocation point |
|---|---|---|---|
| Controller action (Puma thread) | Current attributes (thread-local) |
before_action |
after_action |
| Sidekiq job | Current attributes (thread-local, reset per job) |
Top of perform |
ensure block in perform |
| Rack middleware | Request env (env['vault_key']) |
Middleware call, before app.call |
After app.call returns |
Related questions
Does this work with the stripe-ruby gem or only Net::HTTP?
The vault key pattern calls proxy.keybrake.com instead of api.stripe.com, so it bypasses the stripe-ruby gem's internal HTTP client. For Rails backends, the cleanest approach is to call the proxy directly via Net::HTTP or Faraday in your service objects, rather than using Stripe::PaymentIntent.create. If you need stripe-ruby's response object types, you can configure a custom API base: Stripe.api_base = "https://proxy.keybrake.com/stripe" in a per-request block using a thread-local override — but this pattern is fragile with Puma's thread pool. Raw HTTP calls give you more control and clearer semantics for the proxy pattern.
Will CurrentAttributes cause issues with Puma's thread pool reuse?
ActiveSupport::CurrentAttributes is explicitly designed for Puma thread pool reuse. Rails calls Current.reset between requests automatically via the CurrentAttributes::ExecutionContextCallback middleware added to your stack. This means even if Puma reuses a thread for a new request, Current.vault_key_token is always nil at the start of a request — you cannot leak a previous request's vault key to a new request. This is a documented guarantee of CurrentAttributes, not an implementation detail.
How do I handle cases where the agent makes multiple Stripe calls in one request?
Issue one vault key per request, not one per Stripe call. The vault key's daily_usd_cap applies across all calls made with that key during its TTL. For a request that makes three Stripe calls — create payment intent, capture, and list recent charges — issue one vault key with a cap of max_single_request_spend * 1.2 (20% buffer), set allowed_endpoints to include all three endpoint patterns, and all three calls in the same action share that key. The spend cap accumulates across calls: if the first call charges $50 and the cap is $100, the second call is blocked if it would push total spend over $100.
Further reading
- AI agent API key lifecycle — the four lifecycle phases mapped to Rails request lifecycle and Sidekiq job lifecycle.
- Multi-tenant isolation — per-tenant vault key scoping for Rails SaaS apps with multiple organizations sharing one backend.
- AI agent compliance — using Keybrake audit logs to satisfy SOC 2 and PCI DSS requirements for Rails apps handling agent payments.
- AI agent error handling — handling spend cap 429 responses, retry logic with Rails' built-in retry_on, and error propagation patterns.
- Celery AI agent API key — the Python/Celery equivalent of the Sidekiq pattern above — vault keys in distributed task queue workers.