35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
ActiveRecord Encryption in Rails 7+: Encrypt Sensitive Data Without Leaving Your ORM

ActiveRecord Encryption in Rails 7+: Encrypt Sensitive Data Without Leaving Your ORM

TTB Software
rails, security
A practical guide to encrypting database columns with ActiveRecord Encryption in Rails 7 and 8. Covers setup, key rotation, querying encrypted data, and production gotchas.

ActiveRecord Encryption landed in Rails 7.0 and lets you encrypt specific database columns at the application layer. No extra gems. No separate encryption service. You declare which attributes are encrypted, and Rails handles the rest transparently.

Here’s how to set it up, what actually happens under the hood, and the production pitfalls that the guides skip over.

Why Application-Layer Encryption

Database-level encryption (like PostgreSQL’s pgcrypto or AWS RDS encryption at rest) protects against someone stealing the physical disk or snapshot. It does nothing if an attacker gets database credentials or SQL injection access — they read plaintext just like your app does.

Application-layer encryption means the data is ciphertext in the database. Even with full SELECT * access, an attacker sees gibberish. Your Rails app decrypts on read and encrypts on write, using keys that live outside the database entirely.

This matters for PII fields: email addresses, phone numbers, social security numbers, API tokens stored for integrations. The kind of data that regulators and breach notification laws care about.

Setting Up Keys

Rails needs three keys for its encryption scheme. Generate them:

bin/rails db:encryption:init

This outputs something like:

active_record_encryption:
  primary_key: EGY8WhulUOXixybod7ZWwMIL68R9o5kC
  deterministic_key: aPA5XyALhf75NNnMzaspW7akTfZp0lPY
  key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz

Drop these into config/credentials.yml.enc:

bin/rails credentials:edit

Paste the block under active_record_encryption. Rails picks them up automatically.

For production, you can also use environment variables. In config/application.rb:

config.active_record.encryption.primary_key = ENV["AR_ENCRYPTION_PRIMARY_KEY"]
config.active_record.encryption.deterministic_key = ENV["AR_ENCRYPTION_DETERMINISTIC_KEY"]
config.active_record.encryption.key_derivation_salt = ENV["AR_ENCRYPTION_KEY_DERIVATION_SALT"]

I prefer credentials for single-server setups and environment variables when deploying across multiple nodes where credential file syncing gets messy.

Encrypting Your First Column

Say you have a users table with an email column you want to encrypt. In your model:

class User < ApplicationRecord
  encrypts :email, deterministic: true
  encrypts :phone_number
end

Two modes here:

  • Deterministic (deterministic: true): The same plaintext always produces the same ciphertext. This lets you query the column with WHERE clauses. Use it for fields you need to search by — email addresses, usernames, account numbers.
  • Non-deterministic (the default): Each encryption produces different ciphertext. More secure (prevents frequency analysis attacks) but you cannot query these columns. Use it for fields you only read after loading the record — phone numbers, notes, API keys.

That’s it for the model. No migration needed if the column already exists as a string or text type. Rails stores the ciphertext in the existing column.

What the Ciphertext Looks Like

Before encryption:

roger@example.com

After:

{"p":"nKQ8alYY2sCjBbx0","h":{"iv":"sNq5MRzjEUS4VfMR","at":"ntwU48aTKCYMxFCz8RPA3Q=="}}

It’s a JSON envelope containing the encrypted payload, initialization vector, and authentication tag. The column needs to be large enough to hold this — a string column (255 chars default) handles most values, but very long plaintexts may need text.

Querying Encrypted Data

Deterministic encryption enables queries that look completely normal:

User.find_by(email: "roger@example.com")
# Rails encrypts "roger@example.com" with the deterministic key
# then runs: SELECT * FROM users WHERE email = '{"p":"nKQ8a..."}'

This works for find_by, where, uniqueness validations, and find_or_create_by. From your application code’s perspective, nothing changes.

What does not work:

  • LIKE queries: User.where("email LIKE ?", "%example%") won’t match. The ciphertext has no relation to the plaintext pattern.
  • Database-level sorting: ORDER BY email sorts by ciphertext, which is meaningless.
  • Database-level constraints: A UNIQUE index on the column still works with deterministic encryption (same plaintext = same ciphertext), but CHECK constraints on plaintext values won’t.

If you need full-text search on encrypted fields, you’ll need a separate search index with hashed or tokenized values. That’s a different architecture.

Migrating Existing Data

If you’re adding encryption to a column that already has plaintext data, you need to re-encrypt the existing rows:

class EncryptExistingEmails < ActiveRecord::Migration[7.1]
  def up
    User.find_each do |user|
      user.encrypt
    end
  end

  def down
    User.find_each do |user|
      user.decrypt
    end
  end
end

The encrypt method reads each attribute, encrypts it, and saves. On a table with 100K rows, this took about 45 seconds in my testing on PostgreSQL 16 with a standard db.t3.medium RDS instance. For larger tables, batch it with in_batches:

User.in_batches(of: 1000) do |batch|
  batch.each(&:encrypt)
end

Run this in a maintenance window or behind a feature flag if you can’t afford downtime. The column accepts both plaintext and ciphertext during the transition — Rails detects which it’s reading and handles both.

Key Rotation

Keys get compromised. Compliance frameworks require periodic rotation. ActiveRecord Encryption handles this with a key list:

# config/credentials.yml.enc
active_record_encryption:
  primary_key:
    - new_primary_key_here
    - old_primary_key_here
  deterministic_key:
    - new_deterministic_key_here
    - old_deterministic_key_here
  key_derivation_salt: xEY0dt6TZcAMg52K7O84wYzkjvbA62Hz

Rails tries decryption with each key in order. The first key in the list is used for new encryptions. Old keys decrypt existing data.

After adding the new key, re-encrypt everything:

User.find_each(&:encrypt)

This reads with any matching key and writes with the new primary key. Once all rows are re-encrypted, you can remove the old key from the list.

In production, I’ve found it useful to track re-encryption progress:

total = User.count
User.find_each.with_index do |user, i|
  user.encrypt
  Rails.logger.info("Re-encrypted #{i + 1}/#{total}") if (i + 1) % 10_000 == 0
end

Production Gotchas

Column Size

The JSON ciphertext envelope adds overhead. A 50-character email becomes roughly 150 characters encrypted. If your column is varchar(100), you’ll hit truncation. Audit your column sizes before enabling encryption:

class EnlargeEmailColumn < ActiveRecord::Migration[7.1]
  def change
    change_column :users, :email, :text
  end
end

Switching from string to text removes the length limit entirely. The performance difference is negligible on modern PostgreSQL.

Database Dumps and Imports

If you dump your production database and import it into staging, the staging environment needs the same encryption keys to read the data. This sounds obvious, but I’ve seen teams waste hours wondering why staging shows garbled data after a fresh import.

Either share keys across environments (less secure) or re-encrypt the dump for the target environment’s keys.

Console Access

rails console in production decrypts transparently:

User.first.email
# => "roger@example.com"  (decrypted automatically)

User.first.email_before_type_cast
# => "{\"p\":\"nKQ8a...\"}"  (raw ciphertext)

This is fine for debugging but means anyone with console access can read encrypted data. Restrict production console access accordingly.

Performance

Encryption and decryption add CPU overhead per attribute access. In benchmarks on Ruby 3.3 with Rails 7.2:

  • Encrypting a single attribute: ~0.02ms
  • Decrypting a single attribute: ~0.015ms
  • Loading 1,000 records with 2 encrypted attributes: adds ~30ms total

For typical web requests loading a handful of records, this is invisible. For batch jobs processing millions of rows, it adds up. Profile before committing to encrypting high-volume columns.

ActiveRecord Callbacks

Encryption happens transparently through the attribute API. If you have before_save callbacks that inspect the raw column value, they’ll see plaintext (as expected). But if you’re doing raw SQL inserts bypassing ActiveRecord, you’ll write plaintext to an encrypted column. Rails won’t complain — it’ll just fail to decrypt that row later.

Stick to the ActiveRecord API for encrypted columns. If you must use raw SQL, encrypt manually:

encrypted = User.encrypt_attribute(:email, "roger@example.com")
ActiveRecord::Base.connection.execute(
  "UPDATE users SET email = #{ActiveRecord::Base.connection.quote(encrypted)} WHERE id = 1"
)

When Not to Use It

ActiveRecord Encryption isn’t the right tool everywhere:

  • File encryption: Use Active Storage with a custom service or client-side encryption instead.
  • Password hashing: Keep using has_secure_password with bcrypt. Encryption is reversible; password storage should not be.
  • Columns used in complex SQL: If you need LIKE, BETWEEN, joins on the column, or aggregate functions, encryption breaks those queries. Consider hashing or tokenization for searchability.
  • Very high throughput writes: If you’re inserting millions of rows per hour into an encrypted column, benchmark the CPU cost first.

FAQ

Can I encrypt columns on existing tables without downtime?

Yes. Adding encrypts :column_name to the model is backward-compatible. Rails reads both plaintext and ciphertext from the column. You can then gradually re-encrypt rows in background jobs while the app serves traffic normally. New writes are encrypted immediately.

Does ActiveRecord Encryption work with PostgreSQL, MySQL, and SQLite?

It works with all three. The encryption happens in Ruby before the value reaches the database adapter, so the database engine never sees plaintext. The ciphertext is stored as a regular string. I’ve tested it in production with PostgreSQL 15 and 16 specifically, but the Rails test suite covers all three adapters.

How is this different from the attr_encrypted gem?

The attr_encrypted gem (now largely unmaintained) required separate encrypted_* columns and stored the IV separately. ActiveRecord Encryption uses the same column, stores everything in a JSON envelope, and integrates directly with Rails’ attribute API — meaning validations, dirty tracking, and type casting all work without configuration. It’s also maintained by the Rails core team rather than a third-party gem.

What encryption algorithm does Rails use?

AES-256-GCM by default, which provides both confidentiality and authentication (tamper detection). The key derivation uses PBKDF2 with the salt you configure. You can customize the cipher, but AES-256-GCM is the standard recommendation and what NIST and most compliance frameworks expect.

Do encrypted columns affect database indexing?

For deterministic encryption, yes — you can index the column and the index works correctly because identical plaintexts produce identical ciphertexts. For non-deterministic encryption, an index is technically possible but useless since the same value encrypts differently each time. Only add indexes to deterministically encrypted columns.

T

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation