Active Record Encryption is one of those Rails features that feels intimidating until you wire it up once. After that, it is mostly a matter of choosing the right columns, picking which attributes need deterministic queries, and migrating old data carefully. In this post, we will go from zero setup to a version you can actually ship.
Active Record Encryption was introduced in Rails 7, and it gives you application-level encryption for sensitive model attributes like email addresses, phone numbers, notes, tax identifiers, and other PII. Rails encrypts before writing to the database and decrypts when loading the record, so your app code still works with plain values.
What problem does it solve?
Database encryption at rest is good, but it is not the full story.
If someone gets access to:
- a database dump
- a snapshot
- raw SQL logs
- a backup copied somewhere it should not be
they can still read the data if it is stored as plain text.
Active Record Encryption adds another layer. Your Rails app can still work with the real values, but the database stores ciphertext instead.
That said, it is worth being clear about what this is not:
- It is not a replacement for good access controls.
- It is not a replacement for encrypted disks or managed database encryption.
- It does not mean every encrypted field should become queryable.
The sweet spot is sensitive attributes that your app needs to read, but that should not be exposed in raw database storage.
Step 1: Generate the encryption keys
Rails ships with a task for this:
bin/rails db:encryption:init
That command prints the values you need to add to credentials:
# config/credentials.yml.enc
active_record_encryption:
primary_key: your_primary_key_here
deterministic_key: your_deterministic_key_here
key_derivation_salt: your_key_derivation_salt_here
If you prefer environment variables, you can also configure them explicitly:
# config/application.rb
config.active_record.encryption.primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"]
config.active_record.encryption.deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"]
config.active_record.encryption.key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"]
For most Rails apps, credentials are the simplest option.
Step 2: Start with a real schema
Let’s say we are building a Customer model and we want to protect:
email_addressphone_numberssn_last4support_notes
One important detail: encrypted data needs more storage than plain text.
As a practical rule:
- Use
textcolumns by default for encrypted fields. - If you need indexing on a short value like email, size the column generously.
Here is a solid starting migration:
class CreateCustomers < ActiveRecord::Migration[8.0]
def change
create_table :customers do |t|
t.string :email_address, limit: 510, null: false
t.text :phone_number
t.text :ssn_last4
t.text :support_notes
t.timestamps
end
add_index :customers, :email_address, unique: true
end
end
Why is email_address a bigger string column?
Because short encrypted values grow. Rails stores encrypted payload plus metadata, and the result is Base64-safe text. If you want to keep a unique index on a short field, you need to plan for that extra size.
Step 3: Declare the encrypted attributes
Now we can encrypt at the model level:
class Customer < ApplicationRecord
encrypts :email_address, deterministic: true, downcase: true
encrypts :phone_number
encrypts :ssn_last4
encrypts :support_notes
validates :email_address, presence: true, uniqueness: true
end
This is where the first important design choice appears.
Non-deterministic encryption
This is the default:
encrypts :support_notes
If you encrypt the same value twice, Rails will produce different ciphertext each time. That is better from a security perspective, but it means exact-match database queries do not work reliably.
That makes non-deterministic encryption a good choice for:
- free-form notes
- comments
- internal descriptions
- anything you only read by loading the record
Deterministic encryption
This is what we used for email_address:
encrypts :email_address, deterministic: true, downcase: true
Deterministic encryption makes the same input produce the same ciphertext, which allows:
find_by(email_address: ...)- uniqueness validations
- unique indexes
That makes it a good fit for lookup fields like email addresses.
The downcase: true part is also important. Email lookups are usually case-insensitive, and downcase: true normalizes the value before encrypting it.
Step 4: Use it like a normal model
Once the model is declared, working with it feels very normal:
customer = Customer.create!(
email_address: "[email protected]",
phone_number: "+1 555 123 4567",
ssn_last4: "4242",
support_notes: "Prefers contact by email in the morning."
)
customer.email_address
# => "[email protected]"
Customer.find_by(email_address: "[email protected]")
# => same customer
Behind the scenes, Rails stores encrypted values in the database, but your app code keeps dealing with usable plain values.
You can also inspect the ciphertext for debugging:
customer.ciphertext_for(:email_address)
That can be helpful when you want to confirm a field is really encrypted and not silently staying in plain text.
Step 5: Know what you can and cannot query
This is the part that usually trips people up.
This works:
Customer.find_by(email_address: "[email protected]")
This is a bad idea if phone_number is non-deterministic:
Customer.find_by(phone_number: "+1 555 123 4567")
So the rule is simple:
- If you need exact-match queries, use deterministic encryption.
- If you do not need queries, keep the default non-deterministic encryption.
Do not make everything deterministic just because it feels more convenient. Deterministic fields leak more pattern information than non-deterministic ones, so use them only where the product actually needs lookup behavior.
Step 6: Migrating an existing model safely
If you already have live plain-text data, do not flip encrypts on and hope for the best. Migrate in stages.
Temporary migration settings
During the transition, enable support for mixed encrypted and unencrypted data:
# config/application.rb
config.active_record.encryption.support_unencrypted_data = true
config.active_record.encryption.extend_queries = true
This lets Rails:
- read old unencrypted values without crashing
- query deterministic attributes while both encrypted and plain-text rows coexist
These settings are for migration periods only. They should not be your final state.
Backfill the data
Once the model has encrypts declarations, you can re-save existing records in batches:
class BackfillCustomerEncryption
def self.run
Customer.in_batches(of: 500) do |relation|
relation.each do |customer|
customer.update!(
email_address: customer.email_address,
phone_number: customer.phone_number,
ssn_last4: customer.ssn_last4,
support_notes: customer.support_notes
)
end
end
end
end
That looks repetitive, but it is very explicit: read the current value, assign it again, and let Rails persist the encrypted version.
You could wrap this in a rake task:
# lib/tasks/encryption.rake
namespace :data do
desc "Encrypt existing customer data"
task encrypt_customers: :environment do
BackfillCustomerEncryption.run
end
end
Then run:
bin/rails data:encrypt_customers
Finish the migration
After the backfill is complete and you have confirmed the data is encrypted:
- Remove
support_unencrypted_data. - Remove
extend_queriesif you only enabled it for the migration. - Deploy again with the stricter configuration.
That is the point where your app stops tolerating old clear-text rows.
A small but production-friendly example
Here is a compact setup I would feel good about shipping as a first version:
class Customer < ApplicationRecord
encrypts :email_address, deterministic: true, downcase: true
encrypts :phone_number
encrypts :ssn_last4
encrypts :support_notes
validates :email_address, presence: true, uniqueness: true
end
class CreateCustomers < ActiveRecord::Migration[8.0]
def change
create_table :customers do |t|
t.string :email_address, limit: 510, null: false
t.text :phone_number
t.text :ssn_last4
t.text :support_notes
t.timestamps
end
add_index :customers, :email_address, unique: true
end
end
That gives you:
- encrypted storage for sensitive fields
- exact-match lookups by email
- case-insensitive email uniqueness
- a safe default for notes and other non-queryable sensitive values
Gotchas worth knowing early
1. Do not commit encryption keys
This sounds obvious, but it is the whole game. Store them in Rails credentials or environment variables, never in source control.
2. Use deterministic encryption only when you need it
If you are not querying a field, do not force it into deterministic mode.
3. Be careful with column sizes
Encrypted values are larger than the original input. text is the easiest safe default. Short indexed columns need extra planning.
4. downcase: true changes the stored value
If preserving original case matters for display, look into ignore_case: true instead. That requires an additional original_<column_name> column.
5. Migration flags should be temporary
support_unencrypted_data is great for transitions and not great as a forever setting.
Testing it
A small model spec goes a long way:
RSpec.describe Customer, type: :model do
it "encrypts the email address" do
customer = Customer.create!(email_address: "[email protected]")
expect(customer.ciphertext_for(:email_address)).to be_present
expect(customer.email_address).to eq("[email protected]")
end
it "finds deterministically encrypted email addresses" do
customer = Customer.create!(email_address: "[email protected]")
expect(Customer.find_by(email_address: "[email protected]")).to eq(customer)
end
end
Even two or three tests like this will protect you from configuration regressions later.
Final thoughts
Active Record Encryption is one of the nicest examples of the Rails philosophy: powerful, declarative, and surprisingly approachable once you know the few important tradeoffs.
If you are starting fresh, the most practical approach is:
- Put keys in credentials.
- Encrypt only the fields that are actually sensitive.
- Use deterministic encryption only for values you must query exactly.
- Migrate old data in batches.
- Turn the migration compatibility flags back off when you are done.
That is more than enough to go from “we should probably protect this data” to something real and usable in production.