ruby 77 lines · 4 tabs

ActiveRecord Encryption for PII Fields

Shared by codesnips Jan 2026
4 tabs
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :name, null: false

      # Ciphertext is larger than plaintext, so use text columns.
      t.text :email, null: false
      t.text :ssn

      # Deterministic HMAC of the email for lookups and uniqueness.
      t.string :email_bidx, null: false

      t.timestamps
    end

    add_index :users, :email_bidx, unique: true
  end
end
4 files · ruby Explain with highlit

This snippet shows how to encrypt personally identifiable information at the application layer using Rails' built-in encrypts macro, while keeping the ability to look records up by encrypted values via a deterministic blind index.

The core problem is that database-level encryption (like disk encryption) protects data at rest but leaves plaintext visible to anyone with a query connection. ActiveRecord Encryption instead encrypts the column contents before they leave the app, so a leaked database dump or a compromised replica reveals only ciphertext. The trade-off is that encrypted columns cannot be searched with an ordinary WHERE email = ?, because non-deterministic encryption produces different ciphertext each time.

In the create_users migration, the columns are widened to text because ciphertext plus its embedded IV and auth tag is far larger than the source string. A separate email_bidx column stores a deterministic HMAC of the normalized email, and it carries a unique index so uniqueness can still be enforced at the database level without ever storing the raw address.

The User model declares encrypts :email, deterministic: true and encrypts :ssn — the SSN uses the stronger non-deterministic mode since it never needs to be queried, while the email is deterministic so equal inputs map to equal ciphertext. The before_validation hook normalizes and hashes the email into email_bidx using an HMAC keyed by a dedicated secret, and find_by_email queries that blind index rather than the encrypted column. Deterministic encryption leaks equality (an attacker can tell two rows share an email), which is an accepted trade-off for lookup capability; the free-text SSN avoids that leak entirely.

The encryption initializer wires the three required key components — primary_key, deterministic_key, and key_derivation_salt — from credentials, and enables support_unencrypted_data so a backfill can run against a table that still holds plaintext. The BackfillEncryptionJob re-saves records in batches, which triggers the encryption callbacks and populates email_bidx; running it in find_each batches keeps memory bounded over large tables. Once the backfill completes, support_unencrypted_data should be turned off so the app stops silently reading legacy plaintext.


Related snips

Share this code

Here's the card — post it anywhere.

ActiveRecord Encryption for PII Fields — share card
Link copied