Active Record Encryption in Rails 8

Storing sensitive user data like social security numbers, API keys, or personal identification documents requires more than just HTTPS and secure passwords. If an attacker gains database access, unencrypted sensitive fields become an immediate liability. Rails 8 provides built-in Active Record Encryption that makes protecting data at rest straightforward and maintainable.

The Problem: Sensitive Data in Plain Text

Consider a typical user profile that stores a government ID number or payment account details. Without encryption, a database dump exposes this information directly:

# Direct database query shows plain text
mysql> SELECT government_id FROM users WHERE id = 1;
+----------------+
| government_id  |
+----------------+
| 123-45-6789    |
+----------------+

Active Record Encryption solves this by transparently encrypting data before it reaches the database and decrypting it when loaded into models. The application code remains clean, while the database stores only ciphertext.

Setting Up Encryption Keys

Rails 8 requires three keys for encryption: a primary key, a deterministic key, and a key derivation salt. Generate these using the built-in Rails task:

$ bin/rails db:encryption:init

This outputs credentials to add to your encrypted credentials file:

# Run: EDITOR="code --wait" bin/rails credentials:edit

# config/credentials.yml.enc
active_record_encryption:
  primary_key: aGVsbG93b3JsZHRoaXNpc2Fwcmlt
  deterministic_key: ZGV0ZXJtaW5pc3RpY2tleWV4YW1wbGU=
  key_derivation_salt: c2FsdHlzYWx0eXNhbHR5c2FsdHk=

For production environments with separate key management, configure environment variables in the initializer:

# config/initializers/active_record_encryption.rb

Rails.application.configure do
  config.active_record.encryption.primary_key = ENV["AR_ENCRYPTION_PRIMARY_KEY"]
  config.active_record.encryption.deterministic_key = ENV["AR_ENCRYPTION_DETERMINISTIC_KEY"]
  config.active_record.encryption.key_derivation_salt = ENV["AR_ENCRYPTION_KEY_DERIVATION_SALT"]
end

Encrypting Model Attributes

With keys configured, declare encrypted attributes in models using the encrypts macro. The simplest form uses non-deterministic encryption, which produces different ciphertext each time—even for identical values:

# app/models/user.rb

class User < ApplicationRecord
  encrypts :government_id
  encrypts :medical_notes
end

Create a migration to add these columns as text or string types. Rails handles the encryption transparently:

# db/migrate/20251231120000_add_encrypted_fields_to_users.rb

class AddEncryptedFieldsToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :government_id, :text
    add_column :users, :medical_notes, :text
  end
end

Now the application code works exactly as before, but database contents are encrypted:

# Usage remains transparent
user = User.create!(government_id: "123-45-6789", medical_notes: "Annual checkup complete")
user.government_id  # => "123-45-6789"

# But the database stores ciphertext
mysql> SELECT government_id FROM users WHERE id = 1;
+----------------------------------------------------------+
| government_id                                            |
+----------------------------------------------------------+
| {"p":"aWdIc2R...encoded_ciphertext...","h":{"iv":"..."}} |
+----------------------------------------------------------+

Deterministic vs Non-Deterministic Encryption

Non-deterministic encryption provides stronger security because identical values produce different ciphertext, preventing pattern analysis. However, this makes querying encrypted columns impossible.

When queries are necessary—such as finding a user by encrypted email—use deterministic encryption:

# app/models/user.rb

class User < ApplicationRecord
  encrypts :email, deterministic: true
  encrypts :government_id  # non-deterministic, more secure
  encrypts :medical_notes  # non-deterministic, more secure
end

Deterministic encryption allows find_by and where queries:

# Works with deterministic encryption
User.find_by(email: "[email protected]")
User.where(email: ["[email protected]", "[email protected]"])

# Does NOT work with non-deterministic encryption
User.find_by(government_id: "123-45-6789")  # Always returns nil

Choose deterministic encryption only for fields that require querying. Prefer non-deterministic encryption for maximum security on fields that are only read, not searched.

Encrypting Existing Data

For applications with existing unencrypted data, Rails provides a migration path. First, add the encrypts declaration with support for unencrypted reads:

# app/models/user.rb

class User < ApplicationRecord
  encrypts :government_id, support_unencrypted_data: true
end

Create a migration task to encrypt existing records:

# lib/tasks/encryption.rake

namespace :encryption do
  desc "Encrypt existing user government IDs"
  task encrypt_government_ids: :environment do
    User.find_each do |user|
      # Reading and saving triggers encryption
      user.update!(government_id: user.government_id)
    end
    puts "Encrypted #{User.count} user records"
  end
end

Run the task, then remove the support_unencrypted_data option once migration completes:

$ bin/rails encryption:encrypt_government_ids

Handling Key Rotation

Security best practices recommend periodic key rotation. Rails 8 supports multiple keys, allowing decryption with old keys while encrypting new data with the current key:

# config/credentials.yml.enc

active_record_encryption:
  primary_key:
    - new_primary_key_2025  # Current key (first position)
    - old_primary_key_2024  # Previous key for decryption
  deterministic_key: current_deterministic_key
  key_derivation_salt: consistent_salt_value

After adding the new key, re-encrypt existing data to use the new key:

# lib/tasks/encryption.rake

namespace :encryption do
  desc "Re-encrypt all user data with current key"
  task rotate_keys: :environment do
    User.find_each do |user|
      user.encrypt
    end
  end
end

Common Mistakes to Avoid

Several pitfalls can undermine encryption efforts:

  • Logging encrypted values: Ensure encrypted attributes are filtered from logs by adding them to config.filter_parameters
  • Serialization leaks: Be careful with to_json or API responses that might expose decrypted values unintentionally
  • Backup encryption: Database backups contain encrypted data, but ensure backup systems cannot access decryption keys
  • Search functionality: Full-text search on encrypted fields requires additional architecture like blind indexes
# config/initializers/filter_parameters.rb

Rails.application.config.filter_parameters += [
  :government_id,
  :medical_notes,
  :password,
  :ssn
]

Testing Encrypted Attributes

Testing encrypted models requires no special setup—encryption works transparently in the test environment:

# spec/models/user_spec.rb

RSpec.describe User, type: :model do
  describe "encrypted attributes" do
    it "encrypts government_id transparently" do
      user = User.create!(government_id: "123-45-6789")
      
      # Reload from database
      user.reload
      
      expect(user.government_id).to eq("123-45-6789")
    end
    
    it "allows querying deterministic encrypted email" do
      user = User.create!(email: "[email protected]")
      
      found = User.find_by(email: "[email protected]")
      
      expect(found).to eq(user)
    end
  end
end

Summary

Active Record Encryption in Rails 8 provides a robust foundation for protecting sensitive data at rest. The key decisions when implementing encryption are:

  • Use non-deterministic encryption by default for maximum security
  • Use deterministic encryption only when querying is required
  • Store encryption keys in Rails credentials or environment variables, never in source control
  • Plan for key rotation from the start
  • Filter encrypted attributes from logs and be mindful of API serialization

With these patterns in place, applications can store sensitive user data with confidence that a database breach does not automatically mean a data breach.

10 claps
← Back to Blog