June 16, 2025
Paweł Dąbrowski
Founder / Software Engineer
If you want to increase the security level in your application, you should definitely look into encryption. It would make more sense, primarily if you deal with PII (Personally Identifiable Information) provided by the users. Since version 7, Rails provides Active Record Encryption out of the box.
The encryption API is straightforward, but you have to be aware of a few details to successfully migrate existing database columns to the encrypted version in your Rails application. This article covers them all and provides step-by-step instructions on how to migrate the production database.
This article is based on a sample Rails 8.0.2 application, using PostgreSQL database engine with the User
model that contains the following attributes: email
, date_of_birth
, full_address
, and passport_issue_year
.
The setup phase is very short and involves invoking the install command to generate the configuration values that you can use for the encryption in your application:
./bin/rails db:encryption:init
You can either copy these values and paste directly into the credentials file:
./bin/rails credentials:edit
Or you can set them as environment variables and refer to them in the configuration:
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"]
We need to prepare our application for the moment when the configuration has already changed, but the values in the database haven’t been encrypted yet. To ensure that the application will work fine during the transition, we need to update the configuration and add the following lines:
config.active_record.encryption.support_unencrypted_data = true
This configuration allows the application to read unencrypted data correctly from columns marked as encrypted. Also, there are two types of encryption:
Having the above in mind, we have to ensure that the application will be able to query unencrypted values that are already defined as encrypted with the deterministic flag:
config.active_record.encryption.extend_queries = true
With the above configuration, our application won’t throw errors in the middle of transition when the changes are deployed, but the database won’t be updated yet with the encrypted values.
Our configuration is ready for the encryption migration, but our database columns are not yet there. Only string and text columns can be encrypted because encrypted values are simply text tokens, so it’s impossible to store them in date or integer columns.
Also, due to the nature of the encryption, the encrypted value can be four times longer than the original, unencrypted one. We should have this in mind when selecting between string and text column types.
The preparation process will consist of the following steps:
string
or text
text
when there is a risk of exceeding the default 255-character limit
I'm going to leave the email as is, but convert full_address
to text
, as we may expect longer values. Regarding the date_of_birth
and passport_issue_year
, I will convert them to strings as those values are not long and won't exceed the limit, even in the encrypted version.
It's time to ensure that when calling date_of_birth
, we get a date object, and when calling passport_issue_year
, we get an integer. We have to define these attributes and corresponding data types explicitly in the User
model:
class User < ApplicationRecord
attribute :date_of_birth, :date
attribute :passport_issue_year, :integer
end
With the following changes, the database schema is prepared for the encryption process. However, the application logic remains unchanged.
Now, it's time to define which attributes we would like to be encrypted in the User
model:
class User < ApplicationRecord
attribute :date_of_birth, :date
attribute :passport_issue_year, :integer
encrypts :email, deterministic: true
encrypts :full_address, :date_of_birth, :passport_issue_year
end
It's important to place the attribute definition before the encrypts
definition, otherwise the code won't work.
To track which records were migrated, I will add a migrated_to_encrypted
column to the User
model, which will be set to false
by default. Once the migration is done, the attribute will be updated to true
.
Do the update in the background and process each record in a separate worker to avoid performance issues and easily retry the migration when needed, or process single records.
Start with creating migrate_users_job.rb
that will queue a worker for each user:
class MigrateUsersJob
include Sidekiq::Job
sidekiq_options retry: false
def perform
User.where(migrated_to_encrypted: false).find_each do |user|
MigrateUserJob.perform_async(user.id)
end
end
end
Now, the job that will update encrypted attributes:
class MigrateUserJob
include Sidekiq::Job
sidekiq_options retry: false
ATTRIBUTES_TO_MIGRATE = %i[email full_address date_of_birth passport_issue_year]
EncryptionMigrationError = Class.new(StandardError)
def perform(user_id)
user = User.find(user_id)
user.encrypt
if ATTRIBUTES_TO_MIGRATE.all? { |attr| user.encrypted_attribute?(attr) }
user.migrated_to_encrypted = true
user.save
else
raise EncryptionMigrationError
end
end
end
You can now deploy the changes and run the migration job in the production environment. You may experience issues if the records in the database do not match the validation rules currently set in the models. However, with the temporary flag, you can easily spot which records were not migrated, fix the data, and then retry the migration.
If the migration went well, you can prepare the follow-up PR to clean up the codebase. First, remove the User#migrated_to_encrypted
column. Then delete migration jobs: MigrateUserJob
and MigrateUsersJob
.
The last step is to remove the temporary configuration that helped to achieve backward compatibility:
config.active_record.encryption.support_unencrypted_data = true
config.active_record.encryption.extend_queries = true
Before deploying the cleanup changes, ensure that the application is passing tests and the QA session.