Matthew Lindfield Seager

Matthew Lindfield Seager

Encrypted Credentials in Rails

Rails can encrypt keys for you. It encrypts and decrypts them using other keys. Once your keys have been decrypted using the other keys, you can look up your keys by their keys. If that sounds confusing, this post may be the key to getting a better understanding of Rails Credentials.

TL;DR

Learning 1: RAILS_ENV=production rails credentials:edit, RAILS_ENV=development rails credentials:edit and rails credentials:edit all do exactly the same thing. The way to edit per-environment credentials is by adding the --environment flag; rails credentials:edit --environment production.

Learning 2: Regardless of which file they’re defined in, credentials are accessed again using Rails.application.credentials.name or Rails.application.credentials.name[:child][:grandchild][:great_grandchild] if you nest credentials.

Learning 3: If an environment-specific credentials file is present, Rails will only look in that file for credentials. The default/shared credentials file will not be used, even if the secret is missing from the environment-specific file.

Bonus Tip: ActiveSupport::OrderedOptions is a handy sub-class of hash that gives you dynamic method based on hash key names and the option of raising an error instead of retuning nil if a requested hash key doesn’t have a value assigned.

That’s the short version. Read on if you’d like some additional context, a bit information about how the Credentials “magic” actually works and some practical implications. If you’re super bored/interested, read on beyond that for some mildly interesting trivia or edge cases.


A note on terminology As noted earlier, the term keys is very overloaded. Credentials, passwords or secrets are often referred to as keys, as in API key. Additionally, Rails Credentials uses a key to encrypt each credential file. Finally, hashes, the data type Rails Credentials uses to store decrypted credentials in memory, use keys to retrieve values. Which is why we can accurately but unhelpfully say, keys are encrypted by other keys and looked up by yet different keys. In an effort to avoid confusion I have used the following naming convention throughout this post:

  1. The term Credentials (upper case C) is shorthand for Rails Credentials, the overall Rails facility for storing secrets needed by your application.
  2. The terms credentials or credential (lower case C) refer to the actual application secret(s) you store using Credentials.
  3. The term “name” is used to refer to the hash key (or YAML key) of a credential.
  4. The term “file encryption key” is used to refer to the main secret that Credentials uses to encrypt a set of your credentials on disk.
  5. Any other unavoiable use of the word “key” will be preceded by a descriptor such as hash key, YAML key or API key.

Background

I’m using Rails’ built in Credentials feature to store Google Workspace API credentials. After seeing how easy it was to delete real Google Workspace users from the directory, I decided I really should be using the test domain Google generously allows education customers to set up. So after adding a Service Account to our test domain, it was time to separate the production credentials from the development/staging credentials.

My first thought was to run RAILS_ENV=production rails credentials:edit but when I did, the existing credentials file opened up. I then tried to specify the development environment to see if maybe I had it backwards but once again the same credentials file opened up.

There’s nothing in the Rails Guide on security about it but eventually I found a reference to the original PR for this feature which explains the need to specify the environment using the --environment flag.

Here are some of the things I learned while exploring this corner of Rails.


Learnings

1. RAILS_ENV has no effect on the rails credentials commands

The command rails credentials:edit, regardless of the value of RAILS_ENV, will always attempt to open and decrypt the default credentials file for editing; config/credentials.yml.enc. The way to change which environment you would like to edit credntials for is to use the --environment flag.

When dealing with the default credentials file, the encryption key is obtained from the RAILS_MASTER_KEY environment variable if it is set, otherwise the contents of config/master.key is tried. When you close the decrypted file, it is re-encrypted with the encryption key.

If you specify an environment using (for example) rails credentials:edit --environment production, then a different credentials file will be opened (or created) at config/credentials/production.yml.enc. This one might use the same encryption key or it might not. If the same RAILS_MASTER_KEY environment variable is set, it will use that to encrypt the file. If it isn’t set, it will use (or create on first edit) a different key stored in a correspondingly named file, config/credentials/production.key in our example.

Here’s a table showing 4 different credential commands, the files they maintain, and the location of the encryption keys used for each file:

Command Credentials File Encryption key Environment Variable Encryption Key File (if ENV VAR not set)
rails credentials:edit config/credentials.yml.enc RAILS_MASTER_KEY /config/master.key
rails credentials:edit --environment development /config/credentials/development.yml.enc RAILS_MASTER_KEY /config/credentials/development.key
rails credentials:edit --environment test /config/credentials/test.yml.enc RAILS_MASTER_KEY /config/credentials/test.key
rails credentials:edit --environment production /config/credentials/production.yml.enc RAILS_MASTER_KEY /config/credentials/production.key

2. First level credentials are accessible as methods, child credentials must be accessed via their hash keys

With your credentials successfully stored, they can all be accessed within your Rails app (or in the console) via the Rails.application.credentials hash. The credential names in the YAML are symbolized so credential_name: my secret password can be accessed via Rails.application.credentials[:credential_name]. For your convenience, first level credentials are also made available as methods so you can access them using Rails.application.credentials.name.

If you nest additional credentials, they form a hash of hashes and can be accessed using standard hash notation. I can’t imagine why you’d want more than 2, maybe 3, levels in a real application but if you had six levels of nesting the way to access the deepest item would be Rails.application.credentials.name[:child][:grandchild][:gen_4][:gen_5][:gen_6]. Child credentials can’t be accessed as methods, you must use the hash syntax to access them: Rails.application.credentials.name[:child].

If you want an exception to be raised if a top level credential can’t be found, use the bang ! version of the method name: Rails.application.credentials.name!. Without this you’ll just get back nil. You will need to manually guard against missing child credentials yourself though. One way to do this would be Rails.application.credentials.name![:child].presence || raise(KeyError.new(":child is blank"))

3. Only one credentials file can be used in any given environment

If an environment-specific credentials file is present, Rails will only look in that file for credentials. The default credentials file will not be used, even if the requested credential is missing from the environment-specific file and set in the default file.

One implication of this is that, if you use environment specific files, you will need to duplicate any shared keys between files and keep them in sync when they change. I would love to see Credentials improved to first load the default credentials file, if present, with all its values and then load an environment-specific file, if present, with its values. Shared credentials could then be stored in the default file and be overridden (or supplemented) in certain environments.


Take aways

1. Environment-specific files introduce new challenges

Choosing to adopt environment-specific files means choosing to keep common credentials synchronised between files. Small teams may be better off sticking with namespaced credentials in the default file. To my mind, the neatest option is simply adding an environment YAML key where necessary:

# credentials.yml.enc
aws_key: qazwsxedcrfv # same in all environments

google:
  development: &google_defaults
    project_id: 12345678 # shared between all environments
    private_key:  ABC123
  test:
    <<: *google_defaults # exactly the same as development
  production:
    <<: *google_defaults
    private_key:  DEF456 # override just the values that are different
    
    
# Application
Rails.application.credentials.aws_key
Rails.application.credentials.google[Rails.env.to_sym][:project_id]
Rails.application.credentials.google[Rails.env.to_sym][:private_key]

If separate files are needed, I think the next best option would be to try to limit yourself to two files; one shared between dev, test and staging, and another one for production. However this will get messy the moment you need to access any production credentials in staging (for example). You’ll then need to either keep all 3 files fully populated or start splitting the contents of one or both of the files using the [Rails.env.to_sym] trick.

2. File Encryption Key sharing/storage

If you lose your file encryption Key, the contents of the encrypted file will also be lost. Individuals could store this file in local backups or store the contents of the file in their password manager.

If multiple team members need access, the file encryption key should be stored in a shared vault. I’m a big fan of 1password.com myself.

3. File Encryption Key rotation

One way to rotate your File Encryption Keys is to: 1. Run rails credentials:edit to decrypt your current credentials 2. Copy the contents of that file before closing it 3. Delete credentials.yml.enc and master.key (or other file pairs as necessary) 4. Re-run rails credentials:edit to create a new master.key 5. Paste the original contents in, then save and close. This will create a new credentials.yml.enc file 6. Update the copy in your password manager 7. Clear your clipboard history if applicable


Edge cases and trivia

If RAILS_MASTER_KEY is not set and the encryption key file (see table above) does not exist, a new encryption key will be generated and saved to the relevant file. The encryption key file will also be added to .gitignore. The new encyption key will not be able to decrypt existing credential files.

Whilst RAILS_MASTER_KEY lives up to it’s “master key” name and is used by all environment files, config/master.key does not and is not.

The --environment flag accepts either a space, --environment production, or an equals sign, --environment=production.

If you specify a credential name (YAML key) with a hyphen in it, the .name syntax won’t work. Similarly if you name a child credential with a hyphen, you will need to access it with a strange (to me) string/symbol hybrid. The required syntax is Rails.application.credentials[:'hyphen-name'] and Rails.application.credentials.name[:'child-hyphen-name'] respectively.

You can’t change the file encryption key by running credentials:edit and then changing the file encryption key whlie the credentials file is still open. The original file encryption key is kept in memory and used to re-encrypt the contents when the credentials file is closed.

Even though you don’t use RAILS_ENV to set the environment, the environment name you pass to the --environment flag should match a real envrionment name. If you run rails credentials:edit --environment foo, Rails will happily generate foo.yml.enc and foo.key but unless you have a Rails environment named foo the credentials will never be (automatically) loaded.

Some YAML files in Rails are parsed with ERB first. Credentials files are not so you can’t include Ruby code in your credentials file.

YAML does allow you to inherit settings from one section to another. By appending &foo to the end of a parent key you can “copy” all the child keys to another YAML node with <<: *foo. See the example in Takeaway 1 above for a fuller example.

In development, Rails.application.credentials will not have any credentials loaded (in @config) until after you first access one explicitly or access them all with Rails.application.credentials.config.

This may also be theoretically true in production, but in practice the production environment tries to validate secret_key_base at startup, thereby loading all the credentials straight away.

Whilst technically the credentials live in the Rails.application.credentials.config hash, Credentials delegates calls to the :[] and :fetch methods to :config. This allows us to drop the .config part of the method call.

Missing methods on Rails.application.credentials get delegated to :options. The options method simply converts theconfig hash into ActiveSupport::OrderedOptions, a sub-class of Hash. OrderedOptions is what provides the .name shortcuts and the .name! alternatives. I can think of a few other use cases where OrderedOptions would be handy! If you already have a hash you need to use ActiveSupport::InheritableOptions to convert it into an OrderedOptions collection.