How we improved sharing and managing our application secrets within a team environment.
As a company trusted with other companies' valuable content and knowledge, security is a top priority for our engineering team at Slab. We're always looking for ways to improve and one area we identified was how we manage application secrets on a growing team.
When we set out on this journey over two years ago, we were using Elixir 1.11. At the time, the recommended method of managing production secrets was to bundle a config/prod.exs
with hardcoded secrets, as this file was included at compile time. We shared this file internally with the engineering team using 1Password.
There were a few drawbacks to using this approach in a Kubernetes environment:
config/prod.exs
as a volume mount, but we would be relying on Kubernetes secrets. We will explain later on why this isn't ideal.The current recommended method of managing secrets in Elixir applications is to place configurations in config/runtime.exs
and have it resolved at runtime via environmental variables.
This is a better approach, as it makes it easier to comply with the twelve-factor app methodology. Relying on environmental variables to store sensitive data creates a credential leakage issue as they can be read by:
Some may consider this a good enough compromise, but at Slab, we wanted to achieve a higher standard for our security, especially in a zero-trust security model.
That led us to search for a better solution to manage sensitive data throughout our development, provisioning, and deployment pipelines.
HashiCorp's Vault is a secure storage system for sensitive data and is widely considered to be the golden standard for secret management.
Its feature set is compatible with our use case, and it would be our first choice for a secret storage service. But we decided not to go with Vault because:
As a startup, we highly value low overhead and complexity, which led us to search for alternatives.
Fortunately for Slab, Google had recently released Secret Manager in general availability. Like Vault, it's a secure storage system for sensitive data designed for high availability, follows the principle of least privilege, and helps meets audit and compliance requirements, like SOC 2.
Secret Manager, by default, encrypts all secrets at rest using AES-256 and TLS in transit. For an added layer of security, you can use Customer-managed encryption keys (CMEK).
Secrets have versions, which are immutable objects that store sensitive data. A given release may point to a specific secret version or access its latest
alias. This makes it easy to manage application secrets, especially when rolling back application configuration.
Since Secret Manager is a managed service, it becomes an excellent choice for startups, and larger organizations, who want an easy and low-maintenance solution for secure secret storage.
Let's see how we can create our first secret using Secret Manager:
# create initial secret
gcloud secrets create DB_PRIMARY_PASSWORD
# create secret's first version with a random password
gcloud secrets versions add test --data-file=- \
<<< $(openssl rand -base64 32)
# get latest version data
gcloud secrets versions access latest --secret=DB_PRIMARY_PASSWORD
We now have our database password stored in Secret Manager, but how do we leverage it in our application?
To enable our application to read secrets from Secret Manager, we use Hush, a runtime configuration loader for Elixir applications, which supports multiple providers such as Google Secret Manager.
When hush is configured as a Config Provider in release mode, it resolves the application's configuration during the system boot process and restarts the application with the correct configuration applied to it. Using this method, we can start an Elixir application without writing sensitive data to disk, as all secrets are loaded at runtime.
So how do we configure Hush in our application? First, we add our required dependencies.
# mix.exs
def deps() do
[
{:goth, "~> 1.3"},
{:hush, "~> 1.0"},
{:hush_gcp_secret_manager, "~> 1.0"},
]
end
Secondly, we configure the application release to use Hush as a config provider. When the erlang VM starts, Hush will fetch the configuration from Secret Manager and trigger a restart of the application with the full configuration.
# mix.exs
def project() do
[
# ...
releases: [
slab: [
config_providers: [{Hush.ConfigProvider, nil}]
]
]
]
end
If we use Hush configuration in a non-release environment, we'll want Hush to resolve any configuration before our application starts.
# lib/application.ex
defmodule Slab.Application do
def start(_, _) do
unless Hush.release_mode?(), do: Hush.resolve!()
# ...
end
end
Lastly, we configure hush_gcp_secret_manager
to use goth, an Elixir client to authenticate with Google Cloud, and the Google Cloud project to read the secrets from. Make sure you set the GOOGLE_PROJECT_ID
environment variable in the application.
# config/runtime.exs
config :hush_gcp_secret_manager,
project_id: System.fetch_env!("GOOGLE_PROJECT_ID"),
goth: [name: Hush.Goth]
Now we can use Hush to read configuration from Secret Manager, for example, let's configure our Ecto repo.
# config/runtime.exs
alias Hush.{GcpSecretManager, SystemEnvironment}
if config_env() == :prod do
config :slab, Slab.Repo,
hostname: {:hush, SystemEnvironment, "DB_PRIMARY_HOST"},
password: {:hush, GcpSecretManager, "DB_PRIMARY_PASSWORD"},
pool_size: {:hush, SystemEnvironment, "SLAB_REPO_POOL_SIZE", cast: :integer}
end
You can read more about Hush and its API on its project page.
A service account is a special type of Google account intended to represent a non-human user that needs to authenticate and be authorized to access data in Google APIs. ― Understanding Service Accounts
These service accounts are used throughout Google Cloud. Some of them are provisioned by default. For example, all Compute VMs are assigned the PROJECT_NUMBER-compute@developer.gserviceaccount.com
service account to them as the default service account. This machine can now talk to your Google Cloud APIs using said service account.
You can also create service accounts for specific purposes and only grant them the permissions they need, fulfilling the principle of least privilege.
To interact with Google Cloud APIs, you need a service account and a way to impersonate it. In Google Cloud, there are two ways.
Unlike normal users, service accounts don't have passwords, they instead use RSA key pairs for authentication. You can generate and download the private service account key and use it to exchange a JWT Bearer token for interacting with Google Cloud. If you used a quick start guide for GCP, there is a good chance you created a service account key file (ends in .json
).
On the other hand, service account keys can become a security risk if not managed carefully. Threats related to service account keys include credential leakage, privilege escalation, and information disclosure. The best way to mitigate these threats is to avoid user-managed service account keys whenever possible.
Google Cloud API clients that implement Application Default Credentials will always try to locate a service account key, or if the service running the application has an attached service account, can use the metadata server to authenticate to said service account without using a key at all.
One example of this is the compute service account that is attached to a VM by default, any API calls made by an API client running on that VM that use Application Default Credentials will use the compute service account without ever having to use a private key.
In Google Kubernetes Engine, we can go a step further and use Workload Identity, which enables your Kubernetes cluster to impersonate IAM service accounts to access Google Cloud services.
To do so, we need to have a GKE cluster with workload identity enabled and create a Google Service Account with the roles the app needs, in this case, to access Secrets.
export PROJECT_ID=slab
export GOOGLE_SA=google-sa
export K8S_SA=kubernetes-sa
# create google service account
gcloud iam service-accounts create $GOOGLE_SA
# grant the google service account secret manager role
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:$GOOGLE_SA@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
Then we need to create the Kubernetes service account and annotate it to impersonate the Google service account we just created.
# create kubernetes service account
kubectl create serviceaccount $K8S_SA
# allow kubernetes to impersonate the google service account (in default namespace)
gcloud iam service-accounts add-iam-policy-binding $GOOGLE_SA@$PROJECT_ID.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:$PROJECT_ID.svc.id.goog[default/$K8S_SA]"
# annotate kubernetes service account with google's service account
kubectl annotate serviceaccount $K8S_SA \
iam.gke.io/gcp-service-account=$GOOGLE_SA@$PROJECT_ID.iam.gserviceaccount.com
Now we need to configure our application pods to use the newly create Kubernetes service account.
apiVersion: v1
kind: Pod
metadata:
name: slab
spec:
serviceAccountName: $K8S_SA
Any Google client library can access Google Cloud APIs via Workload Identity without using a service account key.
Security has always been a top priority at Slab, and we are constantly aiming to push the boundaries for our customers. The use of the best new technologies is one of the ways we strive to meet and exceed their security needs.
We've been using Google Secret Manager and Hush reliably in production for over two years now. We're confident in our choice and usage of them and wanted to share our learnings with the wider Elixir community.