RUBY ON RAILS · 15 MIN READ ·

Rails ActiveRecord Encryption: Encrypting PII at Rest with Deterministic and Non-Deterministic Modes

Rails ActiveRecord encryption guide: encrypt PII at rest with deterministic and non-deterministic modes, key management, and safely migrating production data.

Rails ActiveRecord Encryption: Encrypting PII at Rest with Deterministic and Non-Deterministic Modes

A SaaS founder pinged me on a Tuesday morning because his SOC 2 auditor had opened a ticket titled “PII stored in plaintext” against three columns in his users table: email, phone, and date of birth. The auditor wanted encryption at rest inside the application, not just at the disk level, and the founder wanted to know how long it would take to fix. The honest answer was two afternoons of careful work if we used Rails ActiveRecord encryption, and three weeks if we tried to reinvent it with a homegrown attr_encrypted-style solution. This is the walkthrough I ended up writing for his team, cleaned up into a post: how to turn on encryption, when to use deterministic mode, how to keep the app queryable, and how to migrate existing rows without a maintenance window.

After nineteen years of Rails I have watched a lot of teams over-complicate this. The attr_encrypted gem was the go-to for years, but since Rails 7 the framework itself ships a solid, audited implementation that covers the two modes most apps actually need. Rails ActiveRecord encryption is now the boring answer, and it is almost always the right one.

What Rails ActiveRecord Encryption Actually Is

Rails ActiveRecord encryption, introduced in Rails 7.0 and hardened in 7.1 and 8, is a per-column encryption layer that sits between your model and the database. You declare encrypts :email in a model, and Rails transparently encrypts on write and decrypts on read. The ciphertext lives in the same column — usually a text — and the plaintext never touches the database. Disk snapshots, replication streams, base backups, pg_dump output, and rogue read replicas all contain only the ciphertext.

The cipher is AES-256-GCM. The keys are derived from three secrets you configure once — a primary key, a deterministic key, and a key derivation salt — and stored in Rails’ encrypted credentials. Every encrypted value is stored as a small binary blob containing the ciphertext, a nonce, an authentication tag, and a header that records which key version and scheme was used. That header is the reason key rotation is practical in this system, and why I recommend Rails’ built-in encryption over rolling your own.

There are two modes and the difference matters. Non-deterministic encryption uses a random nonce per write, so encrypting "alice@example.com" twice produces two different ciphertexts. This is the safer default — it leaks nothing about repeats — but it means you cannot query by that column, because the database has no way to match your encrypted WHERE clause against the stored ciphertext. Deterministic encryption uses a nonce derived from the plaintext, so the same input always produces the same output. You can now where(email: "alice@example.com") and hit an index, but you have leaked equality — anyone with database access can see which rows share the same value.

Pick per column, not per app. Email is deterministic in every app I ship because you have to look users up by it. Phone is usually deterministic for the same reason. Date of birth is non-deterministic — you never WHERE dob = ?, and it is a strong reidentification vector. Social security numbers, passport numbers, and bank account numbers are non-deterministic unless you have a compelling reason otherwise.

Setting Up Rails ActiveRecord Encryption Keys

The first step is generating the three secrets and putting them in encrypted credentials. Rails ships a generator for exactly this:

bin/rails db:encryption:init

This prints a YAML block you paste into config/credentials.yml.enc via bin/rails credentials:edit:

active_record_encryption:
  primary_key: 6qmkiUJ4H8y1XoZ2QpFvKq7RtM3nBeWs
  deterministic_key: iEr8g0d5wJvT2yLpAxNqRvKu7HmBnCoP
  key_derivation_salt: n3qXvJ8w2LpMkTyRfAeSbGhUcQdWzY5V

Two rules. First, generate a different set per environment. Production, staging, and CI each have their own primary key, and you never copy them between environments — the point of encryption is that only the environment that owns the key can read the data. Second, back up production credentials somewhere outside the repo. If you lose the primary key, every encrypted column becomes garbage. AWS Secrets Manager, 1Password, or a printed sheet in a locked drawer all work; a Slack DM to yourself does not.

If you deploy with Kamal or standard Rails credentials, the master key lives in config/master.key (or RAILS_MASTER_KEY env var). If you deploy on a platform that already gives you a KMS integration — AWS KMS, Google Cloud KMS — you can wrap the Rails primary key in the KMS master and unwrap at boot. For most Rails shops the built-in encrypted credentials are enough, and adding a KMS unwrap step is more operational surface area than the threat model justifies.

Declaring Encrypted Columns

Once the keys are in place, the model side is a one-liner per column:

class User < ApplicationRecord
  encrypts :email, deterministic: true, downcase: true
  encrypts :phone, deterministic: true
  encrypts :date_of_birth
  encrypts :bank_account_number
end

downcase: true normalises the plaintext before encryption. This is essential for deterministic columns where you want Alice@Example.com and alice@example.com to match — without it, they encrypt to different ciphertexts and your uniqueness validations lie. ignore_case: true is a related option that preserves the original case for display but matches case-insensitively; use it when the UI needs to show the user’s typing verbatim.

The underlying column stays a text or binary. If you are adding encryption to a new column, define it as t.text :email and let Rails handle the payload framing. If you are converting an existing varchar(255) column, widen it first because the ciphertext is larger than the plaintext:

class WidenEncryptedColumns < ActiveRecord::Migration[8.0]
  def change
    change_column :users, :email, :text
    change_column :users, :phone, :text
    change_column :users, :date_of_birth, :text
  end
end

Run this behind strong migrations so you don’t accidentally rewrite the whole table under a lock during peak hours. On large tables you want change_column_type_concurrently or the equivalent multi-step column swap.

Deterministic Versus Non-Deterministic In Practice

Here is the mental model I use when picking a mode. If the column is on the WHERE side of any query, it has to be deterministic. If it appears in a unique index, it has to be deterministic. If it appears in a HAS_MANY :through join, it has to be deterministic. Otherwise, default to non-deterministic and be safer.

Deterministic mode has one nasty edge case. Because the same input always produces the same output, an attacker with read access to the database can build a rainbow table for common values — email addresses that appear in breach corpora, the top hundred phone number prefixes, the ten most common passwords. If your threat model includes “someone gets a copy of the database file,” deterministic-encrypted columns leak equality but not plaintext. That is much better than plaintext, but it is not perfect. For highly sensitive columns that you do not need to query — SSN, passport number, health data — pick non-deterministic and take the query hit.

You can still search a non-deterministic column, you just cannot use SQL to do it. Load a bounded set of candidates by some other predicate, decrypt in Ruby, and filter:

User.where(country: "NL")
    .find_each
    .select { |u| u.date_of_birth == Date.new(1985, 3, 12) }

This is fine at a hundred rows and terrible at a hundred thousand. Design your indexes around the deterministic columns you do query, and treat non-deterministic columns as write-only most of the time.

Migrating Existing Production Data

The trickiest part of turning on Rails ActiveRecord encryption in an existing app is that you already have plaintext rows sitting in the column you are about to encrypt. Rails ships a support_unencrypted_data mode for exactly this transition:

# config/application.rb
config.active_record.encryption.support_unencrypted_data = true

With this on, reads work whether the row is encrypted or not, and writes always encrypt. You deploy this flag, run a background migration that touches every row, then flip the flag off. The migration is a plain Ruby loop:

class EncryptExistingUsers < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!

  def up
    User.find_each(batch_size: 1000) do |user|
      user.encrypt
    end
  end
end

#encrypt is a Rails-provided instance method that re-reads the plaintext, re-writes the ciphertext, and does nothing if the row is already encrypted. For tables with millions of rows I move this into a Solid Queue job and split the work across shards by primary key range — a pluck(:id).each_slice(10_000) at the top and one enqueued job per slice, so you can restart the migration without redoing the finished ones.

Once every row is encrypted, remove support_unencrypted_data and redeploy. You now have a hard invariant: every read must decrypt, every write must encrypt, and any row that somehow arrives as plaintext raises ActiveRecord::Encryption::Errors::Decryption on read. That last part is what your alerting should watch for — one plaintext row surviving the migration is a PagerDuty in my book.

If your users table also has audit history tracked by PaperTrail — see my post on audit logging with PaperTrail — remember that the versions table stores serialised attributes. You need to encrypt those too, or your compliance win is undone by the audit trail leaking plaintext.

Key Rotation

Every encrypted value carries a header that records which key version encrypted it, so rotating keys is a matter of adding a new key without removing the old one. Rails looks up the version at read time and decrypts with the matching key; new writes always use the current primary. In config/credentials.yml.enc:

active_record_encryption:
  primary_key:
    - <new key here>
    - <old key here>
  deterministic_key:
    - <new key here>
    - <old key here>
  key_derivation_salt: n3qXvJ8w2LpMkTyRfAeSbGhUcQdWzY5V

The first entry is the current primary; the rest are decrypt-only. Deploy this, then either run a background re-encryption to migrate every row to the new key and delete the old entry, or leave both keys in place until the retention period on the old data passes and you can drop it wholesale.

You rotate for two reasons: a key was compromised (rare) or your policy calls for annual rotation (common). For the compromised case, rotate immediately and start the re-encryption job the same day. For scheduled rotation, add a new key each year and drop keys that have not encrypted any row in the last two years. pg_stat_statements and a quick query on the ciphertext header tell you the distribution.

Common Pitfalls

Fixture data. Fixtures live as plaintext YAML, and the test suite tries to insert them directly. Rails handles this if you tell it: set config.active_record.encryption.encrypt_fixtures = true in config/environments/test.rb, and fixtures encrypt on load.

Full-text search. You cannot ILIKE a deterministic-encrypted column, and you certainly cannot on a non-deterministic one. If email search is a product feature — “find users by partial email match” for a support tool — you either index a separate hash column, keep an unencrypted email_local_part column (with legal sign-off), or move the search into a purpose-built index like OpenSearch with its own access controls.

Serialization boundaries. Anywhere you serialise a model to a log line, an error report, an event payload — that decrypted value leaks. Sentry PII scrubbing helps here; see my Sentry PII scrubbing post for the config. But logs are the biggest offender: Rails.logger.info user.inspect bypasses all your careful encryption, so set config.filter_parameters to redact every encrypted attribute name.

Third-party integrations. Stripe, Postmark, and every CRM you sync to need the plaintext values. That is fine — the encryption is at rest, not in flight — but it means your Sidekiq jobs decrypt on the way out. Audit the outbound side of every integration and confirm that the transport layer (TLS) and the receiving side (their retention policy) match your risk appetite.

When To Skip Rails ActiveRecord Encryption

There are three cases where I have talked founders out of it. If the entire database sits on a fully managed platform that provides customer-managed-key encryption at rest and the only threat model is “AWS gets subpoenaed,” disk-level encryption is enough — application-level encryption adds operational surface for no marginal security. If the column is a computed derivative of another column that is not encrypted (a search index built from user names), encrypting the derivative just moves the problem. And if the entire row will move to a purpose-built vault like HashiCorp Vault Transit or AWS KMS Envelope for a compliance framework that requires that specific vendor, use the vendor’s SDK and skip the built-in encryption for that column.

For the other ninety percent of apps — where the auditor’s ticket says “encrypt PII at the application layer” and you want to be done by Friday — Rails ActiveRecord encryption is the answer.

FAQ

Can you query a Rails encrypted column?

Only if you declared it with deterministic: true. Non-deterministic encryption produces a different ciphertext every write, so the database cannot match a WHERE clause. Deterministic encryption produces a stable ciphertext, which lets you query and index — at the cost of leaking equality to anyone with database access.

What happens if you lose the Rails encryption primary key?

Every encrypted column becomes unrecoverable. Rails uses AES-256-GCM, which is a real cipher with no backdoor; there is no reset button. Back up production keys in a secrets manager, not the repo, and rotate them via the multi-key array in credentials.yml.enc so you always have a decrypt-only history.

How do you migrate an existing table to Rails ActiveRecord encryption?

Set config.active_record.encryption.support_unencrypted_data = true, deploy, then run a background job that iterates every row and calls record.encrypt. Once every row is encrypted, disable the flag and redeploy. On tables over a million rows, split the work into Solid Queue jobs by primary key range so you can pause and resume.

No. Full-text indexes read the underlying column values, and encrypted columns look like random binary. If you need search on a PII column, keep the searchable representation in a separate column with its own access controls, or move the search into a dedicated engine with column-level authorisation. Do not try to full-text search encrypted ciphertext.


Need help turning on Rails ActiveRecord encryption without a maintenance window, or want a review of how your app handles PII end-to-end? TTB Software has been shipping production Rails since 2007 and works with founders and engineering teams as a fractional CTO. Nineteen years in, the boring answers are usually the right ones.

#rails-activerecord-encryption #rails-encryption-at-rest #rails-pii-encryption #active-record-encrypts #rails-deterministic-encryption #rails-gdpr-compliance

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software