How To Use Rails Active Storage S3 With Client-Side Encryption

Sharon Mayberg
Tailor Tech
Published in
4 min readApr 27, 2023

Data security is a crucial consideration for any web application that handles user content.
While cloud storage services offer a convenient way to store and manage files, simply storing the data in the cloud is not enough to guarantee its safety.
In order to fully secure user data, it is important to encrypt it before storing it in the cloud.
In this article, we’ll explore how to implement client side encryption with Ruby on Rails’ Active Storage, providing another layer of security for the user data stored in S3.

What is client-side encryption?
Client-side encryption is the act of encrypting your data locally before uploading it, to ensure its security as it passes to the cloud service.
This approach is often used to enhance the security and privacy of sensitive data, by ensuring it is not accessible to unauthorized parties even if the network or the remote server is compromised.
Encrypting our files before uploading them to the cloud is an extra step of security we want to take, so no one except for us can see our sensitive data.

To learn more about Amazon S3 client side encryption, see Protecting data using client-side encryption

What is Active Storage?
Active Storage makes it easy to upload files to a cloud storage service (Amazon S3, Google Cloud Storage, or Microsoft Azure Storage) and attach those files to Active Record objects.
With Active Storage, developers can easily and quickly attach files to database records (like images or documents), without having to manage the storage themselves.

For more information about how to use Active storage, see Active Storage Documentation.

Creating an encryption key
In client side encryption, an encryption key is a secret code used to encrypt and decrypt data on the client side. The encryption key is generated by the developer and is kept secret from anyone who is not authorized to access the data.
You can either create your own encryption key or use a key managed by AWS.
We chose to use the AWS managed kms service for the encryption key. We manage these resources through Terraform but you can create it by yourself following this guide.

Code implementation
To get started, make sure you are using a recent version of aws-sdk-s3 gem, a library that provides a simple interface for developers to interact with Amazon Web Services.

First, we will create a new service that extends the built-in ActiveStorage S3 service and implements client-side encryption.
To do so — Create a new file lib/active_storage/service/encrypted_s3_service.rb With the following content:

# frozen_string_literal: true

require "active_storage/service/s3_service"

module ActiveStorage
class Service::EncryptedS3Service < Service::S3Service
attr_reader :encryption_client

def initialize(bucket:, upload: {}, **options)
super_options = options.except(:kms_key_id, :encryption_key, :key_wrap_schema, :content_encryption_schema, :security_profile)
super(bucket:, upload:, **super_options)
@encryption_client = Aws::S3::EncryptionV2::Client.new(options)
end

def upload(key, io, checksum: nil, **)
instrument :upload, key:, checksum: do
@encryption_client.put_object(
upload_options.merge(body: io, bucket: bucket.name, key:)
)
rescue Aws::S3::Errors::BadDigest
raise ActiveStorage::IntegrityError
end
end

def download(key)
instrument :download, key: do
@encryption_client.get_object(bucket: bucket.name, key:).body.string.force_encoding(Encoding::BINARY)
end
end
end
end

After creating the above file, add the following service to your config/storage.yml file which declares all the services your application uses:

amazon_encrypted:
service: EncryptedS3
region: <region>
bucket: <bucket_name>
kms_key_id: alias/<kms_key_id>
key_wrap_schema: :kms_context
content_encryption_schema: :aes_gcm_no_padding
security_profile: :v2

Common pitfalls:

  • Make sure you do not include the service suffix in storage.yml (in service property) since active storage concatenates it automatically.
  • You should keep the alias/ prefix in the kms_key_id.

Properties explained:

  • kms_key_id — After generating the encryption key in AWS you will get a kms_key_id.
  • key_wrap_schema — The key wrapping schema. It must match the type of key configured. In our case: :kms_context.
  • content_encryption_schema — the only supported value currently is :aes_gcm_no_padding.
  • security_profile — Determines the support for reading objects written using older key wrap or content encryption schemas.
    In this example we are using The EncryptionV2::Client (V2 Client) which provides improved security over the Encryption::Client (V1 Client) by using more modern and secure algorithms.

Using multiple buckets
At this point, our project was already using Active Storage with S3, but without client side encryption. In order to not harm the fetching/retrieving of the already stored (not sensitive) data, we decided to create a separate bucket for sensitive data that will be used with encryption. This is how we defined our storage file:

amazon:
service: S3
region: <region>
bucket: <bucket_name>

amazon_encrypted:
service: EncryptedS3
region: <region>
bucket: <bucket_name>
kms_key_id: alias/<kms_key_id>
key_wrap_schema: :kms_context
content_encryption_schema: :aes_gcm_no_padding
security_profile: :v2

When using multiple storage services, we define a default storage service, which in our project was called amazon, declared as the default one in the application configuration file like this:

config.active_storage.service = :amazon

When attaching files to records that will be stored in the encrypted service, we would need to override the default service for a specific attachment, we can do so using the service option:

class User < ApplicationRecord
has_one_attached :avatar, service: :amazon_encrypted
end

Testing with multiple buckets

When testing our code, we might want to test models that are attached to the non-default bucket. In order to do so, we need to create a new configuration file for the test environment (also, it is strongly suggested that you have separate ones for development and production environments as well so you don’t mix development attachments with production ones).

test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>

amazon:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>

amazon_encrypted:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>

This tells Active Storage where to “upload” the test files to, so it should be a local temporary directory.

Thank you for taking the time to read this article. I hope you found it helpful.
Leave a comment below if you have any questions.

Special thanks to Andrew Kane, for his article Active Storage S3 Client-Side Encryption guide which demonstrates client side encryption with client v1.

--

--