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
class User < ApplicationRecord
encrypts :email, deterministic: true, downcase: true
encrypts :ssn
validates :email, presence: true
validates :email_bidx, uniqueness: true
before_validation :compute_email_bidx
def self.find_by_email(address)
find_by(email_bidx: blind_index(address))
end
def self.blind_index(address)
normalized = address.to_s.strip.downcase
OpenSSL::HMAC.hexdigest("SHA256", bidx_key, normalized)
end
def self.bidx_key
Rails.application.credentials.dig(:email_blind_index_key)
end
private
def compute_email_bidx
return if email.blank?
self.email_bidx = self.class.blind_index(email)
end
end
Rails.application.configure do
creds = Rails.application.credentials.active_record_encryption || {}
config.active_record.encryption.primary_key = creds[:primary_key]
config.active_record.encryption.deterministic_key = creds[:deterministic_key]
config.active_record.encryption.key_derivation_salt = creds[:key_derivation_salt]
# Keep true only while backfilling legacy plaintext rows.
config.active_record.encryption.support_unencrypted_data = true
# Fail loudly if an encrypted attribute is queried in the clear.
config.active_record.encryption.extend_queries = true
end
class BackfillEncryptionJob < ApplicationJob
queue_as :low
def perform(batch_size: 500)
scope = User.where(email_bidx: nil).or(User.where(email_bidx: ""))
scope.find_each(batch_size: batch_size) do |user|
# Re-saving triggers the encrypts callbacks and populates email_bidx.
user.save!(validate: false)
rescue ActiveRecord::RecordNotUnique
Rails.logger.warn("Duplicate email blind index for user=#{user.id}")
end
remaining = User.where(email_bidx: nil).count
Rails.logger.info("Encryption backfill complete, remaining=#{remaining}")
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.