Active Record Encryption Expert
Encrypt sensitive data at the application level using Rails' built-in Active Record Encryption.
Philosophy
Core Principles:
- Encrypt only what needs it — Encryption adds complexity and storage overhead. Be deliberate.
- Deterministic only when you must query — Non-deterministic is more secure. Default to it.
- Keys belong in credentials, not code — Never commit encryption keys. Period.
- Plan migration before encrypting — Existing unencrypted data needs a migration strategy.
- Test with encryption enabled — Fixtures need
encrypt_fixtures: trueor tests will break.
Decision Matrix:
Need to query/find_by this field?
YES → deterministic: true
NO → default (non-deterministic)
Need uniqueness validation?
YES → deterministic: true (+ downcase/ignore_case if case-insensitive)
Need to preserve case but query case-insensitively?
YES → deterministic: true, ignore_case: true (adds original_<column> column)
Just protecting data at rest (logs, backups)?
YES → default (non-deterministic) is perfect
When To Use This Skill
- Adding
encryptsdeclarations to model attributes - Setting up encryption keys for the first time
- Migrating existing unencrypted data to encrypted
- Rotating encryption keys
- Querying deterministically encrypted attributes
- Encrypting Action Text content
- Debugging encryption-related errors (decryption failures, query mismatches)
- Understanding deterministic vs non-deterministic trade-offs
Critical Mistakes to Avoid
🚨 These are the mistakes agents make most often. Read before writing any code.
-
Querying non-deterministic fields —
Model.find_by(field: value)only works withdeterministic: true. Non-deterministic encryption produces different ciphertexts each time, so the database can't match against them. -
Forgetting to generate/configure keys —
encrypts :emaildoes nothing useful without runningrails db:encryption:initand storing keys in credentials. You'll getActiveRecord::Encryption::Errors::Configurationerrors. -
Encrypting fields that need indexing without deterministic mode — Database indexes on non-deterministic columns are useless. If you need a unique index, use
deterministic: true. -
Not planning for existing data — Adding
encryptsto a model with existing rows breaks reads because Rails tries to decrypt plaintext values. Enablesupport_unencrypted_dataduring migration. -
Using
ignore_case: truewithout adding the column — This option requires anoriginal_<column_name>column in the database. Missing it = crash. -
Declaring
serializeAFTERencrypts— For serialized attributes,serializemust come beforeencryptsin the model. Rails processes these declarations in order, and encryption needs to wrap the already-serialized value. -
Trying to rotate keys for deterministic encryption — Key rotation isn't supported for deterministic encryption because the same plaintext must always produce the same ciphertext (that's how queries work). Changing the key silently breaks all lookups.
-
Undersizing string columns — Encrypted payloads are larger. A
string(255)email column needs at leaststring(510)when encrypted. See reference.md for sizing guide.
Instructions
Step 1: Generate and Store Keys
Start here. Without keys configured, encrypts declarations silently produce configuration errors at runtime.
bin/rails db:encryption:init
This outputs three values. Store them in credentials:
bin/rails credentials:edit
active_record_encryption:
primary_key: YehXdfzxVKpoLvKseJMJIEGs2JxerkB8
deterministic_key: uhtk2DYS80OweAPnMLtrV2FhYIXaceAy
key_derivation_salt: g7Q66StqUQDQk9SJ81sWbYZXgiRogBwS
Alternative: Environment variables (12-factor apps):
# 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"]
Verify keys are configured:
bin/rails runner "puts ActiveRecord::Encryption.config.primary_key.present?"
# Should print: true
Step 2: Check Existing Patterns
Search the codebase first to understand existing encryption patterns:
# Find existing encryption declarations
rg "encrypts :" app/models/
# Check for existing encryption config
rg "active_record.encryption" config/
rg "active_record_encryption" config/credentials/
# Check column sizes for fields you plan to encrypt
bin/rails runner "ActiveRecord::Base.connection.columns('users').each { |c| puts \"#{c.name}: #{c.type}(#{c.limit})\" }"
Match existing project conventions — if they're using env vars, use env vars. If credentials, use credentials.
Step 3: Declare Encrypted Attributes
Non-deterministic (default — most secure):
class User < ApplicationRecord
encrypts :ssn
encrypts :medical_notes
end
Deterministic (queryable):
class User < ApplicationRecord
encrypts :email, deterministic: true
encrypts :phone, deterministic: true, downcase: true
end
Action Text:
class Message < ApplicationRecord
has_rich_text :content, encrypted: true
end
With serialized attributes (order matters!):
class Article < ApplicationRecord
serialize :metadata, type: Hash # FIRST
encrypts :metadata # SECOND
end
Step 4: Handle Column Sizing
Before encrypting, check and resize columns if needed.
| Original Column | Encrypted Size Needed |
|-----------------|----------------------|
| string(255) ASCII | string(510) |
| string(255) Unicode | string(1275) |
| string(500) non-Western | string(2255) |
| text | text (no change needed) |
Generate a migration if column is too small:
class ResizeEmailForEncryption < ActiveRecord::Migration[7.1]
def change
change_column :users, :email, :string, limit: 510
end
end
Step 5: Migrate Existing Unencrypted Data
If the table already has data, plan the migration. Without this, Rails tries to decrypt plaintext rows and raises Decryption errors.
Phase 1: Enable coexistence
# config/application.rb
config.active_record.encryption.support_unencrypted_data = true
config.active_record.encryption.extend_queries = true
Phase 2: Encrypt existing records (run as a task or migration)
# lib/tasks/encryption.rake
namespace :encryption do
desc "Encrypt existing user data"
task encrypt_users: :environment do
User.find_each do |user|
user.encrypt
rescue => e
Rails.logger.error "Failed to encrypt user #{user.id}: #{e.message}"
end
end
end
bin/rails encryption:encrypt_users
Phase 3: Verify and disable coexistence
# Verify all records are encrypted
User.find_each do |user|
unless user.encrypted_attribute?(:email)
puts "User #{user.id} still unencrypted"
end
end
Once all data is encrypted, remove the coexistence config:
# Remove these lines:
# config.active_record.encryption.support_unencrypted_data = true
# config.active_record.encryption.extend_queries = true
Step 6: Configure Key Rotation (Non-Deterministic Only)
Key rotation only works for non-deterministic encryption. Deterministic fields must keep their original key — changing it breaks all queries because ciphertexts no longer match.
Add new key to the list — last key encrypts, all keys decrypt:
# credentials
active_record_encryption:
primary_key:
- old_key_abc123 # Can still decrypt
- new_key_xyz789 # Active — encrypts new data
key_derivation_salt: g7Q66StqUQDQk9SJ81sWbYZXgiRogBwS
Re-encrypt existing data with the new key:
User.find_each(&:encrypt)
After re-encryption, remove old keys.
Step 7: Configure Test Environment
Add to config/environments/test.rb:
Rails.application.configure do
config.active_record.encryption.encrypt_fixtures = true
end
Without this, fixture values won't be encrypted and reads will fail.
If migrating data, enable coexistence in test too:
config.active_record.encryption.support_unencrypted_data = true
config.active_record.encryption.extend_queries = true
Step 8: Handle Querying Correctly
Deterministic — queries work naturally:
# These work with deterministic: true
User.find_by(email: "user@example.com")
User.where(email: "user@example.com")
Non-deterministic — queries are impossible:
# These can't work with non-deterministic encryption — each encryption
# produces a different ciphertext, so the DB can't match against it
User.find_by(ssn: "123-45-6789") # => nil (always)
User.where(ssn: "123-45-6789") # => empty (always)
If you need to find by a non-deterministic field, you have two options:
- Switch to
deterministic: true(less secure) - Add a separate deterministic digest/hash column for lookups
Step 9: Uniqueness and Case Sensitivity
Unique validations require deterministic encryption:
class User < ApplicationRecord
validates :email, uniqueness: true
encrypts :email, deterministic: true, downcase: true
end
Don't use case_sensitive: false on the validation — it doesn't work with encrypted fields because the database sees ciphertext, not plaintext. Use downcase: true or ignore_case: true on encrypts instead, which handles case normalization before encryption.
downcase: true — Original case is lost. Simple.
ignore_case: true — Preserves original case but requires an extra column:
# Migration needed:
add_column :labels, :original_name, :string
# Model:
class Label < ApplicationRecord
encrypts :name, deterministic: true, ignore_case: true
end
Quick Reference
encrypts Options
| Option | Default | Purpose |
|--------|---------|---------|
| deterministic: | false | Enable queryable encryption |
| downcase: | false | Lowercase before encrypting (deterministic only) |
| ignore_case: | false | Case-insensitive queries, preserves original (needs original_ column) |
| key_provider: | global | Custom key provider for this attribute |
| key: | nil | Specific encryption key for this attribute |
| previous: | nil | Previous encryption scheme(s) for migration |
| compress: | true | Enable payload compression |
| compressor: | Zlib | Custom compression algorithm |
| message_serializer: | default | Custom serializer for the encrypted payload |
Configuration Options
# config/application.rb
config.active_record.encryption.primary_key = "..."
config.active_record.encryption.deterministic_key = "..."
config.active_record.encryption.key_derivation_salt = "..."
config.active_record.encryption.support_unencrypted_data = false # true during migration
config.active_record.encryption.extend_queries = false # true during migration
config.active_record.encryption.encrypt_fixtures = false # true in test env
config.active_record.encryption.add_to_filter_parameters = true # auto-filter in logs
config.active_record.encryption.store_key_references = false # faster decryption, larger payload
config.active_record.encryption.forced_encoding_for_deterministic_encryption = Encoding::UTF_8
Programmatic API
record.encrypt # Encrypt/re-encrypt all encryptable attributes
record.decrypt # Decrypt all encryptable attributes
record.encrypted_attribute?(:field) # Check if attribute is currently encrypted
record.ciphertext_for(:field) # Read raw ciphertext
# Run code without encryption (reads return ciphertext, writes store plaintext)
ActiveRecord::Encryption.without_encryption { ... }
# Run code that can read encrypted data but can't overwrite it
ActiveRecord::Encryption.protecting_encrypted_data { ... }
Common Error → Fix
| Error | Cause | Fix |
|-------|-------|-----|
| ActiveRecord::Encryption::Errors::Configuration | Keys not configured | Run rails db:encryption:init, store in credentials |
| ActiveRecord::Encryption::Errors::Decryption | Wrong key or corrupted data | Check key config; enable support_unencrypted_data during migration |
| Query returns nil for existing data | Querying non-deterministic field | Add deterministic: true to encrypts declaration |
| undefined column 'original_X' | Using ignore_case: true without migration | Add original_<column> column via migration |
| Fixture tests fail | Fixtures not encrypted | Set encrypt_fixtures = true in test config |
| Serialized attribute silently broken | Wrong declaration order | Put serialize BEFORE encrypts |
Debugging Tips
# Check if encryption is properly configured
bin/rails runner "pp ActiveRecord::Encryption.config"
# Check if a specific record's attribute is encrypted
bin/rails runner "puts User.first.encrypted_attribute?(:email)"
# Read raw ciphertext
bin/rails runner "puts User.first.ciphertext_for(:email)"
# Temporarily disable encryption to see raw database values
ActiveRecord::Encryption.without_encryption do
puts User.first.read_attribute(:email)
end
# Check what's actually stored in the database
bin/rails runner "puts ActiveRecord::Base.connection.select_value(\"SELECT email FROM users LIMIT 1\")"
Anti-Patterns to Avoid
- Encrypting everything — Only encrypt genuinely sensitive fields (PII, financial data, health info). Encrypting
nameorcreated_atadds overhead for no security gain. - Using deterministic when non-deterministic suffices — Deterministic is less secure. Only use it when you need to query by that field.
- Forgetting the migration plan — Adding
encryptsto a model with existing data withoutsupport_unencrypted_datawill crash reads. - Storing keys in the repo — Not in config files, not in seeds, not in ENV defaults in code. Credentials or real env vars only.
- Skipping column resizing — Encrypted data is larger. Undersized columns = truncation = data loss.
- Testing without
encrypt_fixtures— Fixtures load raw YAML values. Without encryption in test, comparisons break. - Rotating deterministic keys — This silently breaks all queries. Deterministic fields must keep their original key.
- Using
case_sensitive: falseon validations — This doesn't work with encrypted fields. Usedowncase: trueorignore_case: trueonencrypts.