attr_encrypted used to be the go-to solution for encryption in Rails applications. However, Rails 7 introduced Active Record Encryption, making application-level encryption a first-class feature. Adding encryption is now as simple as:
// language: ruby class User < ApplicationRecord encrypts :email end
But adding encryption is rarely the hard part. The hard part is understanding what problem you're solving, what trade-offs you're accepting, and what information still leaks after encryption.
This post covers what I've learned about storing sensitive data in Rails, and some of the trade-offs that aren't obvious at first glance.
What Is Sensitive Data?
When people hear "sensitive data", they usually think of passwords. Passwords are actually the easy case because we don't encrypt them - we hash them (with hashing, we cannot recover the original data, whereas encryption allows that)
The harder question is:
The harder question is:
What data would hurt users or the business if a database dump leaked tomorrow?
Some obvious examples:
- Email addresses
- Phone numbers
- Government IDs
- Bank accounts
- Access tokens
- API credentials
But the most sensitive data is often hiding in places nobody thinks about. Examples include:
- Internal notes
- Customer support conversations
- Audit logs
Support notes can contain more personal information than the dedicated profile fields. It might look like this:
>>> Customer called from +84..., recently moved to District 7, updated bank account information...
None of those details belongs to a dedicated field, yet together they can reveal far more than an email address ever could. To conclude, before discussing encryption strategies, spend some time identifying what data is actually sensitive.
Storage-level and Application-level encryption
🔓 Our database is already encrypted
If you’re using AWS RDS, that’s probably true. Most cloud providers enable encryption at rest with just a few clicks. But encryption at rest only protects the physical storage. For example, it helps if:
- Someone steals a hard drive from the data center
- A cloud provider retires or replaces storage hardware
- Physical infrastructure is compromised
What it doesn’t protect against is how data is actually leaked most of the time.
Imagine a developer accidentally uploads a production backup to the wrong S3 bucketusers.sql.gz .The database is encrypted at rest while running in RDS, but the backup now contains every email address and phone number in plaintext.
Or imagine an engineer running this query using a read-only production account:
Imagine a developer accidentally uploads a production backup to the wrong S3 bucket
Or imagine an engineer running this query using a read-only production account:
// language: sql SELECT email, phone FROM users;
Encryption at rest won’t stop that either. Application-level encryption solves a different problem: even if someone gets access to the database itself, they only see ciphertext instead of the original values.
Active Record Encryption
Rails ships with Active Record Encryption. Setup is simple.
Generate keys:
Generate keys:
// language: bash bin/rails db:encryption:init
Then declare encrypted attributes:
// language: ruby class User < ApplicationRecord encrypts :email end
Rails transparently encrypts data before persisting it and decrypts it when reading it back. From the application's perspective:
// language: ruby user.email # => "[email protected]"
From PostgreSQL's perspective:
// language: javascript
{"p":"oq+RFYW8CucALxnJ6ccx","h":{"iv":"3nrJAIYcN1+YcGMQ","at":"JBsw7uB90yAyWbQ8E3krjg=="}}Rails uses
The Search Problem
The moment you encrypt data, somebody asks: Can I still search by email?
The answer is: It depends
The answer is: It depends
Non-deterministic Encryption
By default, Rails uses non-deterministic encryption. Encrypting the same email twice produces different ciphertexts. This is great for security because attackers cannot infer patterns from encrypted values. However, this query no longer works.
// language: ruby User.find_by(email: "[email protected]")
The database has no stable encrypted value to compare against.
Deterministic Encryption
Rails provides deterministic encryption for searchable fields.
// language: ruby encrypts :email, deterministic: true
Now the same plaintext always generates the same ciphertext.
Problem solved? Not exactly.
Searchable Encryption Always Leaks Something
Imagine a table with deterministic encryption
// language: ruby gmail.com => A gmail.com => A gmail.com => A apple.me => B apple.me => B company.com => C
The actual values are hidden, but the patterns are not. An attacker can still see: duplicates, frequency, ...
In many real-world datasets, frequency analysis is enough to make educated guesses about the underlying values.
This isn't a Rails problem. This is a fundamental challenge in searchable encryption research. Multiple academic attacks have demonstrated how search patterns and access patterns can reveal information even when the underlying data remains encrypted.
The trade-off is unavoidable:
This isn't a Rails problem. This is a fundamental challenge in searchable encryption research. Multiple academic attacks have demonstrated how search patterns and access patterns can reveal information even when the underlying data remains encrypted.
The trade-off is unavoidable:
Queryability costs privacy.
The moment you want efficient searches, you start leaking information. The only question is how much.
Blind Indexes
This is where Blind Index becomes interesting. Instead of making ciphertext searchable, we store a keyed hash alongside the encrypted value.
A popular gem is blind_index by Ankane
A popular gem is blind_index by Ankane
// language: ruby class User < ApplicationRecord encrypts :email blind_index :email end
Internally:
// language: text email ├── encrypted value └── blind index
Searches happen against the blind index. Rails computes the same blind index for the search term.
// language: ruby User.find_by(email: "[email protected]")
Advantages
- Encrypted data remains randomized: the same email encrypted twice produces different ciphertexts, making it harder to infer patterns.
- Exact-match lookups remain fast:
find_by(email: "...") can still use the blind index. - Database indexes still work: the blind index column can be indexed like any other database column.
Trade-offs
- Equality still leaks: if two users have the same email, they’ll have the same blind index.
- Frequency analysis remains possible: attackers can still observe which values appear more often, even if they don’t know the actual values. In practice, this is less useful for high-entropy data such as email addresses, API tokens, or UUIDs, where values are typically unique or nearly unique.
- Partial matching is hard: queries like LIKE, prefix search, or fuzzy search generally won’t work on encrypted fields.
In production, blind indexes are generally preferred over deterministic encryption for searchable fields because they allow the underlying data to remain encrypted with randomized (non-deterministic) encryption while providing efficient exact-match lookups through a separate index. This reduces ciphertext leakage, simplifies key rotation, and separates search concerns from data encryption.
Cryptography as a Service
As organizations mature, encryption often moves outside the application. Examples include:
- AWS KMS
- Google Cloud KMS
- HashiCorp Vault
Instead of managing encryption directly inside Rails, the application delegates cryptographic operations to specialized systems.
For example, Rails might ask KMS to encrypt a value:
For example, Rails might ask KMS to encrypt a value:
// language: ruby ciphertext = kms_client.encrypt( key_id: "customer-data-key", plaintext: email )
and decrypt it when needed:
// language: ruby email = kms_client.decrypt(ciphertext)
The exact API varies by provider, but the idea is the same: the application no longer owns the encryption keys.
Benefits
- Centralized auditing: know who used which key and when.
- Key rotation: rotate keys without every application managing the process.
- Access control: restrict which services can decrypt specific data.
- Compliance support: often required in regulated environments.
Costs
- Latency: encryption may require network calls.
- Operational complexity: another system to manage and monitor.
- Additional dependencies: your application now depends on KMS or Vault availability.
For most Rails applications, Active Record Encryption provides an excellent balance between simplicity and security.
Final Thoughts
Active Record Encryption makes encryption feel simple. The API is simple, but the trade-offs are not.
Understanding what you’re protecting, what information still leaks, and what operational costs you’re introducing is still our responsibility as engineers. Encryption isn’t a checkbox. It’s a balance between security, usability, performance, and operational complexity.