Migration choices for ActiveRecord Encryption
After GitHub introduced its paved path for column encryption with ActiveRecord::Encryption, the practical challenge became clear: how do you move existing records—particularly those stored in plaintext or with a legacy encryption scheme—onto the new standard without disrupting writes or risking data integrity? Rails documents the previous encryptor strategy for reading older formats, but leaves the data migration itself as an open exercise.
GitHub’s approach was to combine three pieces: a previous encryptor configured on a per-model basis, a custom ActiveModel type driven by a feature flag (used as an on/off switch rather than a gradual rollout), and a database transition process. For an external audience, the maintenance_tasks gem from Shopify provides a workable way to backfill records through the same pattern.
Why not rely on Rails’ built-in plaintext support?
ActiveRecord::Encryption ships with config.active_record.encryption.support_unencrypted_data, which lets attrs read plaintext as-is while encryption is coming online. That option, however, is global. If decryption fails on one column, you risk exposing ciphertext of other columns that are already encrypted. A previous encryptor works instead as a local override: only the model(s) explicitly configured with that encryptor will accept plaintext.
GitHub also had a secondary safety check. Its previous encryptor applies a schema validator and regex to confirm that a value labeled “plaintext” does not actually have the shape of a Rails-encrypted column’s ciphertext—guarding against accidentally reading an encrypted value as plain.
Feature flags as a kill switch
Rather than using feature flags to gradually ramp encryption from 0% to 100%, GitHub inverted the default. The flag is set to 100% to prevent new encryption, and to 0% to enable it. This avoids a thorny problem: if you ramp up a normal flag across a mixed set of records (some plain, some still in the legacy format, some already encrypted), code reading a column cannot know its format without first attempting decryption, so every path must support both directions indefinitely. An on/off switch sidesteps that ambiguity.
The other benefit is independence between migration efforts. Oher columns in the same model don’t have to stay behind while one column is being migrated; a flag disabled for one column’s upgrade doesn’t regress another column’s encryption status. Since the flag must remain available for developers to upgrade future columns, the flag must be long-lived—which is why a normally short-lived flag is flipped to the “prevent encryption” state by default.
self.attribute(attribute) do |cast_type|
GitHub::Encryption::FeatureFlagEncryptedType.new(cast_type: cast_type, attribute_name: attribute, model_name: self.name)
end
The monkeypatch above installs a custom serializer. The GitHb custom type reads the flag state in its serialize method:
# frozen_string_literal: true
module GitHub
module Encryption
class FeatureFlagEncryptedType < ::ActiveRecord::Type::Text
attr_accessor :cast_type, :attribute_name, :model_name
# delegate: a method to make a call to `this_object.foo.bar` into `this_object.bar` for convenience
# deserialize: Take a value from the database, and make it suitable for Rails
# changed_in_place?: determine if the value has changed and needs to be rewritten to the database
delegate :deserialize, :changed_in_place?
, to: :cast_type
def initialize(cast_type:, attribute_name:, model_name:)
raise RuntimeError, "Not an EncryptedAttributeType" unless cast_type.is_a?(ActiveRecord::Encryption::EncryptedAttributeType)
@cast_type = cast_type
@attribute_name = attribute_name
@model_name = model_name
end
# Take a value from Rails and make it suitable for the database
def serialize(value)
if feature_flag_enabled?("encrypt_as_plaintext_#{model_name.downcase}_#{attribute_name.downcase}")
# Fall back to plaintext (ignore the encryption serializer)
cast_type.cast_type.serialize(value)
else
# Perform encryption via active record encryption serializer
cast_type.serialize(value)
end
end
end
end
end
One significant caveat surfaced from extending ActiveRecord::Type::Text: its ancestor ActiveModel::Type::String implements changed_in_place? only by comparing a string-typed new value to old_value. For a column that was previously encrypted with GitHub’s internal library, the decrypted value and the stored ciphertext never matched—so every record looked “changed.” When transitioning a 2FA recovery-codes field, that triggered unnecessary audit-log events that temporarily produced false security alerts for affected customers. Data was never affected, and the alerts were annotated accordingly, but the fix was to delegate changed_in_place? to the cast_type, which properly deserializes the stored value before comparison.
Key rotation reuses the same migration
ActiveRecord::Encryption holds a key list: encryption always uses the newest entry, but decryption tries each entry until one succeeds or a DecryptionError is thrown. Adding a new key therefore changes only the outcome for records that are subsequently written. Records updated before the key switch stay under the old key unless you bulk-reprocess them.
GitHub reuses its migration task (shown below in code sample 5) to handle rotation as well. Adding the new key and rerunning the task reencrypts each record—the same code path that migrates legacy-formatted data to the current key.
A simplified migration walk-through
The following is an abridged version of GitHub’s internal process, rebuilt with the maintenance_tasks gem instead of an internal transition. It is meant to be reproducible in a Rails application.
Enable the encryption configs
First, generate an encryption key set and load them into your Rails credentials:
bin/rails db:encryption:init
bin/rails credentials:edit
The above command can generate a fresh master.key if one doesn’t exist. Never commit that file. The next step is a previous encryptor specific to the column you are about to migrate:
app/lib/encryption/previous_encryptor.rb
# frozen_string_literal: true
module Encryption
class PreviousEncryptor
def encrypt(clear_text, key_provider: nil, cipher_options: {})
raise NotImplementedError.new("This method should not be called")
end
def decrypt(previous_data, key_provider: nil, cipher_options: {})
# JSON schema validation
previous_data
end
end
end
Then declare that type as the previous strategy for the column:
app/models/secret.rb
class Secret < ApplicationRecord
encrypts :code, previous: { encryptor: Encryption::PreviousEncryptor.new }
end
With this setup, existing plaintext records are read as plaintext while all new writes after the column configuration use the current encoder.
Backfill with a maintenance task
Install the maintenance_tasks gem and create a generator-based task:
bin/rails generate maintenance_tasks:task encrypt_plaintext_secrets
While normal application code should not call encrypt directly (ActiveRecord does that at write-time), it is exactly what you need when looping over historical rows:
app/tasks/maintenance/encrypt_plaintext_secrets_task.rb
# frozen_string_literal: true
module Maintenance
class EncryptPlaintextSecretsTask < MaintenanceTasks::Task
def collection
Secret.all
end
def process(element)
element.encrypt
end
…
end
end
Run the job via one of the gem’s supported methods—command line in a normal install, or the web interface when run from the gem’s hosted example:
After the task completes, you can inspect a row in the Rails console to confirm the value now appears as ciphertext rather than plain:
Once verified, drop the previous encryptor and leave the column configured to normal ActiveRecord encryption:
app/models/secret.rb
class Secret < ApplicationRecord
encrypts :code
end
The combination of per-model previous encryptors and a reversible bulk-operation task keeps a rollout safe enough to operate in production: code that can read both formats stays in place until every row is rewritten, and the process can be stopped or run to any partial state without leaving the app in a broken intermediate mode.



