Leaking an LLM provider secret into a Git repo is a fast way to get a surprise bill or have your account abused. Using Rails encrypted credentials API key storage keeps tokens out of source control while giving your app clean, typed access at boot time. This guide walks through the exact steps to store, retrieve, and rotate LLM keys in a Rails 6+ app.
Why plaintext secrets fail
Environment variables loaded from .env files solve local convenience but scatter secrets across shell histories, CI logs, and container orchestration dashboards. Committed secrets.yml in cleartext is worse. Rails encrypted credentials solve both: the secret blob is committed, but it is AES-encrypted with a master key that lives only on authorized machines and in a CI secret store.
For LLM integrations you typically need several keys (OpenAI, Anthropic, maybe a gateway). Keeping them in one encrypted file with structured YAML beats juggling many ENV vars.
Step 1: Locate or generate the encrypted credentials
Rails ships with config/credentials.yml.enc and a matching config/master.key (or uses ENV["RAILS_MASTER_KEY"]). If you are on a fresh app, generate the pair:
rails credentials:edit
This opens your $EDITOR with a decrypted temporary file. On Rails 6+ you can scope to an environment:
rails credentials:edit --environment production
That writes config/credentials/production.yml.enc and expects config/credentials/production.key or RAILS_MASTER_KEY. Never commit the .key file. Only the .enc file belongs in Git.
Step 2: Add your LLM keys to the encrypted store
When the editor opens, you see YAML. Add a nested block for LLM providers. Using a rails encrypted credentials api key layout like the one below keeps related secrets grouped:
llm:
openai_api_key: "sk-proj-abc123EXAMPLE"
anthropic_api_key: "sk-ant-9f8e7d6c5b4a"
# If you route through a gateway, a single entry suffices:
n4n_api_key: "sk-n4n-2a3b4c5d6e"
Save and close. Rails encrypts the file and writes credentials.yml.enc. The decrypted content never touches disk. You can confirm the encryption worked by catting the file:
cat config/credentials.yml.enc | head -c 80
# random binary garbage, not YAML
Structuring for multiple teams
If you have many models or per-tenant keys, nest deeper:
llm:
providers:
openai:
api_key: "sk-..."
org_id: "org-..."
gateway:
api_key: "sk-n4n-..."
Access patterns stay the same; just chain hashes.
Step 3: Access the rails encrypted credentials api key in Ruby
Read values at runtime through Rails.application.credentials. The return is a Rails::Credentials object that behaves like a hash with indifferent access.
key = Rails.application.credentials.llm[:openai_api_key]
# => "sk-proj-abc123EXAMPLE"
Wrap access in a small factory so the rest of the app does not reference Rails directly:
# app/services/llm/clients.rb
module Llm
class Clients
def self.openai
@openai ||= OpenAI::Client.new(
access_token: Rails.application.credentials.llm[:openai_api_key],
request_timeout: 30
)
end
def self.anthropic
@anthropic ||= Anthropic::Client.new(
access_token: Rails.application.credentials.llm[:anthropic_api_key]
)
end
end
end
This pattern makes the rails encrypted credentials api key usage testable: in tests you can stub Rails.application.credentials or set a dummy key via Rails.application.credentials.stub.
Step 4: Configure the OpenAI-compatible client
Most Ruby LLM gems speak the OpenAI shape. For OpenAI directly:
require "openai"
client = OpenAI::Client.new(
access_token: Rails.application.credentials.llm[:openai_api_key]
)
If you route through a gateway, store one rails encrypted credentials api key and point the client at its OpenAI-compatible endpoint. For example, n4n.ai exposes a single OpenAI-compatible endpoint that addresses 240+ models and applies automatic fallback when a provider is rate-limited or degraded. The client config changes only the base URI:
def gateway_client
OpenAI::Client.new(
access_token: Rails.application.credentials.llm[:n4n_api_key],
uri_base: "https://api.n4n.ai/v1"
)
end
The gateway honors client routing directives and forwards provider cache-control hints, so prefix caching behaves as if you called providers directly. You then pass model: "anthropic/claude-3-5-sonnet" or model: "openai/gpt-4o" in the same call shape.
Step 5: Manage environment-specific secrets
Development and production should not share keys. Edit them separately:
# development (default)
rails credentials:edit
# production
RAILS_ENV=production rails credentials:edit
In CI, export the master key as a protected secret and never print it:
export RAILS_MASTER_KEY=$(vault read -field=master_key secret/ci/rails)
RAILS_ENV=production bundle exec rails runner 'puts Rails.application.credentials.llm[:openai_api_key] ? "present" : "absent"'
If you use Kamal or Docker, mount the master key as a secret file and set RAILS_MASTER_KEY from it at container start. Do not bake the key into the image.
Step 6: Rotate a leaked key
Rotation is a credentials edit plus a provider revoke. Because the master key stays constant, no infrastructure changes:
- Revoke the exposed key in the provider dashboard.
- Run
rails credentials:edit. - Replace the value under
llm:with the new key. - Save, commit
credentials.yml.enc, deploy.
rails credentials:edit
# change llm.openai_api_key to sk-proj-NEW
git add config/credentials.yml.enc
git commit -m "rotate openai key"
git push
On the next deploy the app boots with the new secret. No code references the literal string, so nothing else moves.
Step 7: Verify end-to-end
Verification needs two checks: the credential decrypts, and the key actually authenticates against the API.
Smoke test the decryption
rails runner 'abort "MISSING LLM KEY" unless Rails.application.credentials.llm[:openai_api_key]; puts "creds ok"'
Expected output: creds ok.
Live API call
Write a tiny runner script script/llm_smoke.rb:
require "openai"
key = Rails.application.credentials.llm[:openai_api_key]
abort "no key" unless key
client = OpenAI::Client.new(access_token: key)
resp = client.chat(
parameters: {
model: "gpt-4o-mini",
messages: [{ role: "user", content: "Reply with the single word pong." }],
max_tokens: 5
}
)
puts resp.dig("choices", 0, "message", "content")
Run it:
rails runner script/llm_smoke.rb
# => pong
If you use the gateway client, swap the uri_base and use a model string like "openai/gpt-4o-mini". A successful round-trip proves the stored secret is both decryptable and valid.
Common mistakes
- Committing
master.key– add it to.gitignoreand useRAILS_MASTER_KEYin CI. - Wrong environment –
rails credentials:editedits development by default; production readscredentials/production.yml.enc. - YAML indentation – credentials are YAML; two-space indent, no tabs. A misplaced space yields
nilon lookup. - Caching the client with a stale key – if you memoize the client in a constant or class variable, restart the process after rotation.
- Logging the key – never interpolate
Rails.application.credentials.llm[:openai_api_key]into logs or exception messages.
Testing without the master key
In CI units you often lack the real master key. Use Rails’ built-in test credentials or stub:
# test setup
Rails.application.credentials.stub(:llm, { openai_api_key: "sk-test" }) do
assert Llm::Clients.openai.access_token == "sk-test"
end
Alternatively, generate a separate config/credentials/test.yml.enc with dummy values and run tests with RAILS_ENV=test.
Wrapping the access layer
For larger apps, expose a typed config instead of raw credentials:
# config/initializers/llm_config.rb
LlmConfig = Struct.new(:openai_key, :anthropic_key, :gateway_key) do
def self.load
c = Rails.application.credentials.llm
new(c[:openai_api_key], c[:anthropic_api_key], c[:n4n_api_key])
end
end
Then call LlmConfig.load.openai_key in services. This isolates the rails encrypted credentials api key reads to one file and simplifies mocking.
Following these steps gives you a single encrypted source of truth for every LLM token your Rails app uses, with rotation and verification that take minutes instead of a repo scrub.