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 contextVault key storageIssuance pointRevocation 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

Get early access

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