Introduction
By day 99 of our Rails journey, we're handling real production systems where sensitive data like Social Security numbers, credit card tokens, and health records flow through our applications daily. Rails encrypted attributes: patterns, pitfalls and fixes is essential knowledge because encryption built into ActiveRecord makes protection straightforward, yet mistakes here can silently compromise security or break query performance.
In this article, we'll walk through the practical patterns we've used across e-commerce and SaaS platforms to encrypt sensitive columns safely, handle the deterministic encryption trade-offs, manage keys in Rails credentials, and rotate them without data loss.
Encrypting SSN and sensitive columns
Rails 7+ ships with built-in encryption via the ActiveRecord::Encryption class. The simplest approach is declaring which columns need protection in your model:
class Customer < ApplicationRecord
encrypts :ssn
encrypts :passport_number
encrypts :bank_account
endOnce declared, any SSN you save is automatically encrypted at rest:
customer = Customer.create(ssn: "123-45-6789")
# Database stores encrypted blob, not plain text
# Querying returns decrypted value automatically
customer.ssn # => "123-45-6789"In your database, you'll see something like:
customer.encrypted_ssn
# => "\xD8\xAB\xCD..." (binary encrypted data)The column naming matters: Rails looks for encrypted_#{attribute} by default. If you have an ssn column, rename it to encrypted_ssn in a migration, or use the encrypted: { previous: 'ssn' } option to migrate existing unencrypted data gracefully.
For most sensitive columns—addresses tied to identity, email addresses used for authentication recovery, phone numbers—encryption is automatic and transparent. Your queries and business logic remain unchanged; Rails handles decryption on read.
Deterministic encryption for lookups
Here's where many teams hit their first snag: once you encrypt an attribute, you can no longer query by exact match because encrypted values are different every time:
encrypts :ssn, deterministic: false
# This will NOT work
Customer.where(ssn: "123-45-6789") # Returns empty, even if SSN existsWhy? Each encryption includes a random nonce, so the same SSN encrypts to different ciphertext on every save. For lookups, you need deterministic encryption, which uses a deterministic key derivation so the same plaintext always produces the same ciphertext:
encrypts :ssn, deterministic: true
customer = Customer.create(ssn: "123-45-6789")
Customer.where(ssn: "123-45-6789").first # Works now
# => #The trade-off is security: deterministic encryption reveals patterns. If two customers have the same SSN (shouldn't happen, but imagine email lookups where duplicates are legitimate), the encrypted ciphertexts will be identical, leaking that information to anyone with database access. For authentication flows where you must verify membership in a set—"does this email exist?"—deterministic encryption is necessary and acceptable because the plaintext is already semi-public (the user knows their own email). For truly sensitive data like SSNs where you rarely query, use non-deterministic encryption and a separate indexed hash.
A safer pattern for exact-match lookups on sensitive columns is to store a salted hash separately:
class Customer < ApplicationRecord
encrypts :ssn
before_save :compute_ssn_hash
def self.find_by_ssn(plaintext_ssn)
hash = OpenSSL::Digest::SHA256.hexdigest(plaintext_ssn + Rails.application.secrets.ssn_salt)
where(ssn_hash: hash).first
end
private
def compute_ssn_hash
if ssn_changed?
self.ssn_hash = OpenSSL::Digest::SHA256.hexdigest(ssn + Rails.application.secrets.ssn_salt)
end
end
endThis gives you indexed, queryable lookups without revealing plaintext patterns in the encrypted column.
Keys in credentials and environment management
Rails encryption requires a primary key and optionally older keys for rotation. Store them in config/credentials.yml.enc, not environment variables or hardcoded values:
# config/credentials.yml.enc (edit with: rails credentials:edit)
active_record_encryption:
primary_key: "your-64-character-base64-key-here"
deterministic_key: "another-64-character-base64-key"
key_derivation_salt: "your-32-character-salt"Generate strong keys locally:
SecureRandom.random_bytes(32).then { |bytes| Base64.strict_encode64(bytes) }
# => "AbCdEfGhIjKlMnOpQrStUvWxYz/+123..."In production, Rails reads from RAILS_MASTER_KEY environment variable to decrypt credentials. Your deployment pipeline (GitHub Actions, CircleCI, Kubernetes) should inject the master key at runtime, never commit it to version control.
For local development, Rails generates config/master.key automatically and adds it to .gitignore. Each developer gets their own copy (it's not shared in git), so they can run encrypted migrations locally. For CI/CD, inject the production master key into the container or runner environment.
Test environments should use a separate config/credentials/test.yml.enc with its own config/test.key:
# config/credentials/test.yml.enc
active_record_encryption:
primary_key: "test-key-32-bytes-base64-encoded"
deterministic_key: "test-deterministic-key"
key_derivation_salt: "test-salt"Rails automatically loads the right credentials file based on Rails.env.
Rotating keys safely
Over the lifetime of your application, you'll need to rotate encryption keys: perhaps a developer left and shouldn't have access, or you're migrating to a new HSM. Rails supports a list of previous keys in credentials:
active_record_encryption:
primary_key: "new-production-key"
previous_keys:
- "old-key-from-2023"
- "older-key-from-2022"
deterministic_key: "new-deterministic-key"
previous_deterministic_keys:
- "old-deterministic-key"
key_derivation_salt: "current-salt"Rails tries to decrypt with the primary key first; if that fails, it tries previous keys in order. This allows old data to remain readable during transition. However, to actually re-encrypt all data with the new key, you need a migration task:
# lib/tasks/rotate_encryption.rake
namespace :encryption do
desc "Re-encrypt all encrypted attributes with the new primary key"
task rotate_primary_key: :environment do
Customer.unscoped.find_each do |customer|
# Touch triggers before_save, which does validation
# Force-save to re-encrypt with current primary key
customer.touch if customer.valid?
end
puts "Rotation complete."
end
endRunning rails encryption:rotate_primary_key iterates every record. When you save, Rails uses the current primary key, silently re-encrypting. This is I/O heavy for large tables; batch it or run in background jobs:
Customer.unscoped.find_in_batches(batch_size: 1000) do |batch|
batch.each { |c| RotateEncryptionJob.perform_later(c.id) }
endAfter rotation is complete and you've verified all records decrypt correctly, remove the old key from credentials and deploy. If something goes wrong, keep the old key in previous_keys until you're confident in the migration.
Common mistakes
- Forgetting to rename the column. If your database has
ssn, encryption looks forencrypted_ssn- Using deterministic encryption everywhere. Encrypted ciphertexts in an indexed column reveal equality patterns. Use deterministic encryption only when you absolutely must query; prefer non-deterministic + hash-based lookup for sensitive data.
- Hardcoding keys or storing them in env vars. Rails credentials with
RAILS_MASTER_KEYis the secure default. Env vars are often logged, rotated separately, and harder to audit. Use credentials.- Not testing key rotation locally. Rotation logic is easy to break. Write tests that generate encrypted data, rotate keys, and verify decryption still works. Use separate test credentials to catch issues early.
- Rotating keys without keeping old keys in credentials. If you delete the old key before all data is re-encrypted, you lose access to unrotated records permanently. Keep
previous_keysuntil you're certain rotation is complete.- Encrypting at the wrong layer. If you're querying unencrypted in the database (e.g., reports, analytics, backups), consider application-layer encryption instead of Rails encryption. Rails encryption is transparent to queries but doesn't protect database snapshots or exports.
Key takeaways
- Use
encrypts :column_namein your model to automatically encrypt sensitive data at rest; Rails handles decryption transparently on read. - Rename database columns to
encrypted_*before enabling encryption to avoid silent failures. - Deterministic encryption enables exact-match queries but reveals equality patterns; for sensitive lookups, use non-deterministic encryption + salted hash columns instead.
- Store encryption keys in
config/credentials.yml.enc, never in code or basic env vars; Rails usesRAILS_MASTER_KEYat runtime. - Rotate keys by adding new keys to credentials, running a migration task to re-encrypt data, then removing old keys only after verifying rotation is complete.
- Test key rotation and decryption in CI; encryption logic is security-critical and easy to break silently.






Comments
Loading comments…