Back to blog

Migrating existing columns to be encrypted with Rails

Ruby

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.

Setup

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"]

Make sure you can manage unencrypted attributes

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:

  • Deterministic - you can query the encrypted attributes. A typical example is the email field, which we can use to find users.
  • Nondeterministic - you can't query the encrypted attribute; you can only pull the record from the database and then read the value.

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.

Preparing the attributes to be encrypted

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:

  • Converting non-text columns to string or text
  • Converting existing string columns to text when there is a risk of exceeding the default 255-character limit
  • Ensuring that converted non-text columns, on the Rails level, return proper objects. We don't want the date field to suddenly return a string, as it will break our application in many places

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.

Encryption definition

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.

Preparation for migration

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.

Cleanup

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.

Join the newsletter. Pure knowledge straight to your inbox.

You can opt out anytime.