Securing User Emails in Rails with Lockbox
This is an update to Securing User Emails in Rails with a number of improvements:
- Works with Devise’s email changed notifications
- Works with Devise’s reconfirmable option
- Stores encrypted data in a single field
- You only need to manage a single key
Email addresses are a common form of personal data, and they’re often stored unencrypted. If an attacker gains access to the database or backups, emails will be compromised.
This post will walk you through a practical approach to protecting emails. It works with Devise, the most popular authentication framework for Rails, and is general enough to work with others.
Strategy
We’ll use two concepts to make this happen: encryption and blind indexing. Encryption gives us a way to securely store the data, and blind indexing provides a way to look it up.
Blind indexing works by computing a hash of the data. You’re probably familiar with hash functions like MD5 and SHA1. Rather than one of these, we use a hash function that takes a secret key and uses key stretching to slow down brute force attempts. You can read more about blind indexing here.
We’ll use the Lockbox gem for encryption and the Blind Index gem for blind indexing.
Instructions
Let’s assume you have a User
model with an email field.
Add to your Gemfile:
gem "lockbox"
gem "blind_index"
And run:
bundle install
Generate a key
Lockbox.generate_key
Store the key with your other secrets. This is typically Rails credentials or an environment variable (dotenv is great for this). Be sure to use different keys in development and production.
Set the following environment variables with your key (you can use this one in development)
LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
or add it to your credentials for each environment (rails credentials:edit --environment <env>
for Rails 6+)
lockbox:
master_key: "0000000000000000000000000000000000000000000000000000000000000000"
or create config/initializers/lockbox.rb
with something like
Lockbox.master_key = Rails.application.credentials.lockbox[:master_key]
Next, let’s replace the email field with an encrypted version. Create a migration:
rails generate migration add_email_ciphertext_to_users
And add:
class AddEmailCiphertextToUsers < ActiveRecord::Migration[7.0]
def change
# encrypted data
add_column :users, :email_ciphertext, :text
# blind index
add_column :users, :email_bidx, :string
add_index :users, :email_bidx, unique: true
# drop original here unless we have existing users
remove_column :users, :email
end
end
Then migrate:
rails db:migrate
Add to your user model:
class User < ApplicationRecord
has_encrypted :email
blind_index :email
end
Create a new user and confirm it works.
Existing Users
If you have existing users, we need to backfill the data before dropping the email column. Luckily, we can do this without downtime.
class User < ApplicationRecord
has_encrypted :email, migrating: true
blind_index :email, migrating: true
end
Backfill the data in the Rails console:
Lockbox.migrate(User)
Then update the model to the desired state:
class User < ApplicationRecord
has_encrypted :email
blind_index :email
# remove this line after dropping email column
self.ignored_columns = ["email"]
end
Finally, drop the email column.
Reconfirmable
If you use the confirmable module with reconfirmable
, you should also encrypt the unconfirmed_email
field.
class AddUnconfirmedEmailToUsers < ActiveRecord::Migration[7.0]
def change
# encrypted data
add_column :users, :unconfirmed_email_ciphertext, :text
# blind index
add_column :users, :unconfirmed_email_bidx, :string
add_index :users, :unconfirmed_email_bidx
end
end
And add unconfirmed_email
to the list of encrypted fields and blind indexes:
class User < ApplicationRecord
has_encrypted :email, :unconfirmed_email
blind_index :email, :unconfirmed_email
end
Logging
We also need to make sure email addresses aren’t logged. Add to config/initializers/filter_parameter_logging.rb
:
Rails.application.config.filter_parameters += [:email]
Use Logstop to filter anything that looks like an email address as an extra line of defense. Add to your Gemfile:
gem "logstop"
And create config/initializers/logstop.rb
with:
Logstop.guard(Rails.logger)
Summary
We now have a way to encrypt emails and query for exact matches. You can apply this same approach to other fields as well. For more security, consider a key management service to manage your keys.
Updates
- December 2020: Added Rails credentials instructions
- June 2022: Updated for Lockbox 1.0