active-record-encryption
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.
More from thinkoodle/rails-skills
testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4routing
Expert guidance for defining routes in Rails applications. Use when adding routes, working with resources, nested routes, namespaces, path helpers, routes.rb, RESTful design, API routes, URL helpers, or any routing-related task. Covers resources, singular resources, nesting, namespace vs scope, constraints, concerns, member/collection routes, and route testing.
4form-helpers
Expert guidance for building forms in Rails 8 applications. Use when creating forms, form_with, form helpers, nested forms, select helpers, file uploads, form builders, accepts_nested_attributes_for, fields_for, collection_select, grouped_collection_select, date/time selects, checkboxes, radio buttons, rich text areas, or any form-related view code. Covers model-backed forms, URL-based forms, complex nested attributes with _destroy, custom form builders, CSRF tokens, strong parameters for nested forms, and Stimulus integration.
4turbo
Expert guidance for building modern Rails UIs with Turbo (Drive, Frames, Streams). Use when implementing partial page updates, real-time broadcasts, turbo frames, turbo streams, hotwire patterns, turbo_frame_tag, turbo_stream responses, lazy loading frames, morphing, page refreshes, or any "turbo" related Rails feature. Covers Turbo Drive navigation, Turbo Frames for scoped updates, Turbo Streams for real-time HTML delivery, and Turbo 8 morphing.
4action-cable
Expert guidance for implementing real-time features with Action Cable in Rails applications. Use when working with websockets, channels, subscriptions, broadcasting, live updates, push notifications, or Solid Cable. Covers connection authentication, channel creation, streaming, client-side JS, testing, and deployment. Clarifies when to use Action Cable directly vs Turbo Streams broadcasting.
4