Ruby on Rails Module 10 – Securing Rails applications
Credentials, secrets and Active Record encryption
Keep Rails 8 credentials and secret_key_base safe, rotate keys without logging anyone out, and encrypt sensitive columns you can still query by.
What you will learn
- Manage encrypted credentials per environment and keep the credentials key out of git and container images
- Read the Rails 8.1 parameter filter and tell what a log hides from the rest
- Rotate secret_key_base and encryption keys without logging anyone out
- Pick deterministic or non-deterministic encryption per column and defend the choice
- Encrypt sensitive columns and still query the ones that need it
Before you start
What you’ll learn · Before you start
What you will learn
- Manage encrypted credentials per environment and keep the credentials key out of git and container images
- Read the Rails 8.1 parameter filter and tell what a log hides from the rest
- Rotate secret_key_base and encryption keys without logging anyone out
- Pick deterministic or non-deterministic encryption per column and defend the choice
- Encrypt sensitive columns and still query the ones that need it
Before you start
On this page
A Kaksha engineer who commits the app’s credentials key file to a public repository has leaked every secret the app holds: the
key that signs and encrypts every cookie, the payment token, the SMTP password. A different leak is quieter: an
attacker who copies 50,000 rows of the guardians table from a backup walks away with 50,000 plain phone numbers,
and the column cannot be queried if it was encrypted the wrong way.
A Rails 8.1 app ships three defences for them. Rails credentials keep the API keys and passwords the app needs in one
encrypted file. secret_key_base signs and encrypts what the running app hands to a browser. Active Record encryption
encrypts chosen columns, so a copied backup gives nothing away. Each has a failure mode and a fix.
Where a Rails 8 app keeps its secrets
Rails credentials are one encrypted file and one key file. bin/rails credentials:edit writes
config/credentials.yml.enc, which is encrypted and checked into git, and config/master.key, which holds the key
that decrypts it and is listed in .gitignore. The encrypted credentials: The config/credentials.yml.enc file Rails ships with: a YAML file encrypted with a key read from a git-ignored key file or the RAILS_MASTER_KEY variable, holding secret_key_base and the app's API keys.
file is useless on its own, and the credentials key: The key that unlocks a Rails app's encrypted credentials: kept in a git-ignored key file on a developer laptop, or in the RAILS_MASTER_KEY environment variable on a server. file is useless on its own; the pair is
what matters. A deploy almost never puts the key file on the server: it sets RAILS_MASTER_KEY as an environment
variable instead, so a container image can be identical for development, staging and production.
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Two files on disk. config/credentials.yml.enc is encrypted and checked into git. config/master.key holds the key that decrypts it and is listed in .gitignore. Either half is useless without the other.
- A server reads a variable. A deployed app rarely has the key file. The deploy sets RAILS_MASTER_KEY as an environment variable instead, so one container image serves every environment and carries no key.
- Rails decrypts the file. When the app starts, Rails picks the file of the current environment, falling back to the shared one, decrypts it with the key it found and keeps the values in memory. No database or network is involved.
- Code reads one value. Your code asks for one value, such as Rails.application.credentials.stripe[:secret_key]. Nothing on disk changes, and the decrypted file is never written back.
Per-environment credentials go in config/credentials/production.yml.enc next to its own production.key, and when
that file exists production reads it in place of the shared one. The 8.1 command
bin/rails credentials:fetch stripe.secret_key prints one value for a deploy script, so a shell command never opens
the whole file; the generated .kamal/secrets shows it in a commented example.
Checking the key file in by accident
git add . picks up config/master.key unless the generated .gitignore is intact. If it has already landed in a
commit, rotate every secret the file held and the key itself, then remove the file in a new commit; a history
rewrite does not withdraw copies that have left the server.
What signs cookies and what the log filter hides
secret_key_base is one credential, and the app derives its keys from it with ActiveSupport::KeyGenerator and a
salt per use: one for signed cookies, one for encrypted cookies (the session cookie is one of them), and one for each
named message verifier, such as the one behind generates_token_for tokens. Changing
secret_key_base: The credential Rails derives cookie-signing, cookie-encryption and message-signing keys from, with ActiveSupport::KeyGenerator; one value for the whole app, held in encrypted credentials. therefore signs every user out, and the security guide adds that
encrypted cookies, signed cookies and Active Storage files may be affected too, unless the old value is registered as
a rotation.
The second place a secret shows up is a smaller file. config/initializers/filter_parameter_logging.rb carries the
filter_parameters: The Rails config list whose entries are substring-matched against parameter keys so matching values become [FILTERED] in logs; the generator writes a list that starts with passw, email, secret, token. list:
# Shows what the generated config/initializers/filter_parameter_logging.rb hides from Rails logs.
# The filter list below is the one Rails 8.1.4 writes into a new app (checked by reading the generator template
# in the fixture app). filter_parameters is a key filter, not a value filter: a secret pasted into a comment field
# called notes is not masked.
require "active_support"
require "active_support/parameter_filter"
GENERATED = %i[passw email secret token _key crypt salt certificate otp ssn cvv cvc]
filter = ActiveSupport::ParameterFilter.new(GENERATED)
payload = {
"user" => {
"email_address" => "asha@example.com",
"password" => "the-password", # fictional, matches passw
"password_confirmation" => "the-password",
"pan_card" => "ABCDE1234F", # NOT masked: no key matches
"address" => "12 Rajpur Road, Dehradun",
},
"api_token" => "the-token", # fictional, matches token
"notes" => "wrote the password down in a notebook", # NOT masked: filter_parameters reads KEYS, not values
"amount" => 500,
}
puts "filter list: #{GENERATED.inspect}"
puts "filtered: #{filter.filter(payload).inspect}" Output
filter list: [:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc]
filtered: {"user" => {"email_address" => "[FILTERED]", "password" => "[FILTERED]", "password_confirmation" => "[FILTERED]", "pan_card" => "ABCDE1234F", "address" => "12 Rajpur Road, Dehradun"}, "api_token" => "[FILTERED]", "notes" => "wrote the password down in a notebook", "amount" => 500}
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby filter_parameters_demo.rb
The run shows two facts. The list is a substring match on keys, so password, password_confirmation,
email_address and api_token all become [FILTERED]. And it is a key filter, not a value filter: a password
written into a notes field reaches the log unchanged. Add any key your app writes that holds a secret, and keep
secrets out of free-text fields.
Two production questions follow: rotating secret_key_base without logging everyone out, and encrypting a column
you still need to query. Active Record encryption gives each column one of two shapes. A deterministic column stores
the same ciphertext for the same value, so find_by can still find a row by it. A non-deterministic column, the
default, stores a new ciphertext on every write. Before a push, a secret scanner checks
pasted code or logs for keys and tokens, in the browser.
Key idea
Every cookie key comes from secret_key_base, so changing it signs everyone out unless the old value stays
registered, and the log filter reads key names, so a secret typed into a free-text field still reaches the log.
Checkpoint
Check: Encrypted credentials and the credentials key · The parameter filter in logs
Rotate secret_key_base and encryption keys with no sign-out Pro: opens with a Pro, Premium or Ultimate pass
A rotation of secret_key_base has a sharp edge: every session cookie, signed cookie, encrypted cookie and signed
message in flight was made with the old key, and the new app no longer holds it. Without a rotation registered,
every one of them reads as nil the moment the new build is deployed, which signs every browser out at once. The
fix is to teach the new app the old key, let the two coexist for a window, and remove the old one when it has
emptied.
In this part:
- What rotating the signing key invalidates
- Where the rotation goes in an app, and the boot order that hides a mistake
- The rotation runbook
1 diagram · 2 runnable examples (Ruby) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Encrypt columns you still need to find rows by Pro: opens with a Pro, Premium or Ultimate pass
An encrypted column protects the rows a database leak would hand over in plain text. Rails’ Active Record Encryption
writes a small JSON envelope into the column instead of the plain value, and the running app decrypts it on read.
The decision per column is between a shape that lets find_by keep working, and a shape that hides whether two rows
hold the same value.
In this part:
- What encrypts :phone actually writes
- Which shape to pick, column by column
- Rotation of the encryption keys themselves
3 runnable examples (Ruby, YAML) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: read the ciphertext before writing more code
The questions replay the credentials layout and the filter run: which two files a deploy needs, which file production reads when there are two, and which keys the generated filter hides and which it lets through.
Practice that adapts to you
Practise this lesson
6 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: a Rails log shows password: "[FILTERED]" but also a notes field with the plain text wrote the password down. Why does the filter hide one and not the other?
Because filter_parameters matches keys, not values. The generated list holds key patterns such as :passw, which
catches password, password_confirmation and any other key that contains those letters. notes matches no entry,
so its whole value reaches the log, whatever it says. Add every key your app uses for a secret to the list, and keep
secrets out of free-text fields: a filter that guessed at values would still miss a password that looks like an
ordinary word.
-
Mid to senior
An alert at Kaksha says the production RAILS_MASTER_KEY may have been read by someone who should not have it. secret_key_base is one of the credentials. Rotate it in a way that fits the alert, and keep the next scheduled quarterly rotation of the same key running under its own clock.
-
Mid to senior
Kaksha stores a guardian's phone, address, PAN card number and a free-text note. The team is adding Active Record Encryption. Decide which columns to encrypt and which shape to pick for each, and defend the choices against the shape you did not take.
-
Mid to senior
A Kaksha developer pushes a branch to the public mirror of the app repo and realises after fifteen minutes that the credentials key file is in the diff. The branch has not been merged. Lay out the response for the next 24 hours.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which two files make up Rails credentials, and which of them goes into git?
The encrypted config/credentials.yml.enc goes into git; the key file that decrypts it is git-ignored. Neither is any use without the other, and a server normally gets the key from an environment variable instead of the file.
-
What changes once config/credentials/production.yml.enc exists, and how does a deploy script get a single value?
Production reads that file, with its own key, in place of the shared one. bin/rails credentials:fetch with the value's name prints just that value, so the script never opens the whole file.
-
The credentials key file has landed in a commit. What do you do?
Rotate every secret the credentials held and the key itself, then remove the file in a new commit. Rewriting history does not withdraw copies that have already left the server.
-
Why does changing secret_key_base sign every user out?
The app derives a key from it for each use, with ActiveSupport::KeyGenerator and a salt per use: signed cookies, encrypted cookies such as the session, and each named message verifier. Unless the old value stays registered as a rotation, everything signed or encrypted with it stops reading.
-
The log shows password as [FILTERED] but a notes field holding a password in plain text. Why?
filter_parameters matches key names, as substrings, and never looks at values. notes matches no entry on the list, so its whole value reaches the log.
-
What two shapes can an Active Record encrypted column take?
A deterministic column stores the same ciphertext for the same value, so find_by still finds a row by it. A non-deterministic column, the default, stores a new ciphertext on every write.
Key takeaways
- Encrypted credentials and the credentials key are a pair: the encrypted YAML goes in git, and the key comes from
RAILS_MASTER_KEYon a server and from the git-ignored key file on a developer laptop. - A per-environment file such as
config/credentials/production.yml.encreplaces the shared file when it exists, andbin/rails credentials:fetchprints one value for a deploy script. - Every cookie key is derived from
secret_key_base, so changing it signs every user out unless the old value stays registered as a rotation. filter_parametersmasks values by key name, so a secret typed into a free-text field such asnotesreaches the log.- Active Record encryption stores a column either deterministically, so
find_bystill works on it, or with a new ciphertext on every write, the default.
Terms in this lesson
- credentials key
- The key that unlocks a Rails app's encrypted credentials: kept in a git-ignored key file on a developer laptop, or in the RAILS_MASTER_KEY environment variable on a server.
- deterministic encryption
- A column-encryption shape in Active Record Encryption where the same plaintext always produces the same ciphertext bytes, so find_by on the column hits an index; non-deterministic encryption uses a fresh IV per write.
- encrypted credentials
- The config/credentials.yml.enc file Rails ships with: a YAML file encrypted with a key read from a git-ignored key file or the RAILS_MASTER_KEY variable, holding secret_key_base and the app's API keys.
- filter_parameters
- The Rails config list whose entries are substring-matched against parameter keys so matching values become [FILTERED] in logs; the generator writes a list that starts with passw, email, secret, token.
- key rotation
- Replacing a signing or encryption key with a new one on a schedule or in response to a leak, usually with the old key kept as a fallback verifier for a window so pre-rotation tokens still work.
- secret_key_base
- The credential Rails derives cookie-signing, cookie-encryption and message-signing keys from, with ActiveSupport::KeyGenerator; one value for the whole app, held in encrypted credentials.
References
- Securing Rails Applications (Ruby on Rails)
- Active Record Encryption (Ruby on Rails)
- Configuring Rails Applications (Ruby on Rails)
- ActiveSupport::KeyGenerator (Rails 8.1.4 API) (Ruby on Rails)
- ActiveSupport::MessageEncryptor (Rails 8.1.4 API) (Ruby on Rails)
- ActiveSupport::MessageVerifier (Rails 8.1.4 API) (Ruby on Rails)
- ActiveSupport::MessageVerifiers (Rails 8.1.4 API) (Ruby on Rails)
- Rails::Application, message_verifiers (Rails 8.1.4 API) (Ruby on Rails)
- ActionDispatch::Cookies (Rails 8.1.4 API) (Ruby on Rails)
- Cookies: HTTP State Management Mechanism (draft-ietf-httpbis-rfc6265bis) (IETF)
- ActiveSupport::ParameterFilter (Rails 8.1.4 API) (Ruby on Rails)
- Kamal environment variables (Kamal)
- Secrets Management Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress