n4nAI

Storing LLM API keys with Rails encrypted credentials

A practical guide to managing Rails encrypted credentials API key storage for LLM services, including setup, rotation, and verification steps.

n4n Team4 min read836 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  1. Revoke the exposed key in the provider dashboard.
  2. Run rails credentials:edit.
  3. Replace the value under llm: with the new key.
  4. 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 .gitignore and use RAILS_MASTER_KEY in CI.
  • Wrong environmentrails credentials:edit edits development by default; production reads credentials/production.yml.enc.
  • YAML indentation – credentials are YAML; two-space indent, no tabs. A misplaced space yields nil on 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.

Tagsrailscredentialssecurityapi-keys

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ruby on rails llm integration posts →