Defense in depth for sensitive columns

GitHub encrypts source code at rest, but it also protects sensitive database columns in its Ruby on Rails monolith. That extra layer is meant to blunt two specific risks: an attacker who gains inappropriate database access reading or tampering with sensitive fields, and sensitive data accidentally leaking into logs.

Until recently that protection came from an internal library called Encrypted Attributes. Developers declared a column as encrypted with an API that closely resembled ActiveRecord::Encryption:

class TotpAppRegistration
  encrypted_attribute :encrypted_otp_secret, :plaintext_otp_secret
end

Why switch to ActiveSupport's encryption

The push to move away from the internal library was about developer ergonomics. GitHub-specific patterns tend to get lower adoption than familiar, idiomatic ones, which is bad for security tooling. But there were also operational problems. Encrypted Attributes forced developers to generate and store a separate encryption key—in secure environment variable configuration—for every new column. Because most developers don't deal with encryption daily, that created a bottleneck that required security team support.

ActiveRecord::Encryption looked attractive for its ease of use. The goal was for a developer to write a single line of code, and for the column—whether it was plaintext or already using the previous solution—to smoothly transition to the new encryption. That final API is the same API ActiveRecord::Encryption users know:

class TotpAppRegistration
  encrypts :encrypted_otp_secret
end

Adapting ActiveRecord::Encryption for GitHub scale

Enabling ActiveRecord::Encryption inside the monolith required coordination with GitHub's architecture and infrastructure teams. Several customizations were needed, and any cryptography changes went through security review. The details of those tweaks are below.

Diagram 1: Key access and derivation flow for GitHub’s `ActiveRecord::Encryption` implementation

Storing the primary key securely

Rails' default is credentials.yml.enc for the primary key and static salt that derive the column encryption key. GitHub's key management strategy diverges from that default in two ways: it derives a separate key for each column, and the primary key lives in a centralized secret management system rather than in a credentials file.

Deriving per-column keys from a single primary key

Per-column keys were needed to maintain security isolation without the old manual key management burden. The solution makes use of a Key Derivation Function, which takes three inputs: a primary key, a salt, and an "info" string.

The salt is just table_name_column_name. For a TotpAppRegistrations model with an encrypted_otp_secret column, that's totp_app_registrations_encrypted_otp_secret, which guarantees a different key for every column.

AES256-GCM imposes a caution about encrypting too many values under a single key to avoid nonce reuse. The "info" string is where that guard lives: it is populated with the current year, so each column's key automatically rotates at least yearly.

GitHub stores application secrets in Hashicorp Vault. To follow that standard instead of Rails' credentials file, the team wrote a custom key provider that fetches the key from Vault and applies the KDF logic. The provider behaves like the default DerivedSecretKeyProvider.

Making the security default implicit

The internal team's principle is that tools should not demand implementation knowledge. ActiveRecord::Encryption permits per-column encryptor customization, but product developers shouldn't have to opt in to those strategies each time. The monolith therefore overrides the encrypts model helper so it automatically selects the custom, GitHub-specific key provider with no extra code from the developer:

{
def self.encrypts(*attributes, key_provider: nil, previous: nil, **options)
      # snip: ensure only one attribute is passed
# ...

    # pull out the sole attribute
    attribute = attributes.sole

      # snip: ensure if a key provider is passed, that it is a GitHubKeyProvider
      # ...

    # If no key provider is set, instantiate one
    kp = key_provider || GitHub::Encryption::GitHubKeyProvider.new(table: table_name.to_sym, attribute: attribute)

      # snip: logic to ensure previous encryption formats and plaintext are supported for smooth transition (see part 2)
      # github_previous = ...

    # call to rails encryption
    super(attribute, key_provider: kp, previous: github_previous, **options)
end
}

That API is currently available only within the internal github.com codebase. The team is prototyping whether the approach can be pushed upstream to ActiveRecord::Encryption itself, by moving from a per-class encryption scheme to a per-column scheme.

Turning off compression by default

Compressing a value before encryption leaks more than ciphertext length. Data with repeated patterns—"abcabcabc"—compresses differently than a random-ish string of equal length, exposing information about the plaintext's entropy. For the relatively small values GitHub encrypts, storage savings didn't justify the leak, so compression is now off by default but available through a flag.

What comes next

The decisions above cover the design and tradeoffs behind choosing ActiveRecord::Encryption at GitHub scale. The harder engineering—how to actually convert existing columns from plaintext or the old encoding—is the subject of this series' follow-up post.