Skip to content

lambdaclass/aws_secrets_manager

Repository files navigation

AwsSecrets

Hex.pm Docs

Simple, ergonomic AWS Secrets Manager client for Elixir.

Built on top of the aws-elixir library, this package provides a streamlined interface for reading and writing secrets with automatic JSON handling, caching, and proper error types.

Features

  • Simple API for common operations (get, put, create, delete)
  • Automatic JSON encoding/decoding
  • Built-in caching with configurable TTL
  • Structured error types for better error handling
  • Telemetry integration for observability
  • Support for Finch and Hackney HTTP clients
  • Full support for IAM roles (EC2, ECS, Lambda, EKS)

Installation

Add aws_secrets to your list of dependencies in mix.exs:

def deps do
  [
    {:aws_secrets, "~> 0.1.0"},
    {:finch, "~> 0.18"}  # Recommended HTTP client
  ]
end

Quick Start

1. Configure HTTP Client

Add Finch to your application's supervision tree:

# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    {Finch, name: MyApp.Finch},
    # ... other children
  ]

  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  Supervisor.start_link(children, opts)
end

2. Configure AWS

# config/runtime.exs
config :aws_secrets,
  region: System.get_env("AWS_REGION", "us-east-1"),
  http_client: {AWS.HTTPClient.Finch, finch_name: MyApp.Finch}

3. Use It

# Fetch a secret (automatically decodes JSON)
{:ok, credentials} = AwsSecrets.get("myapp/database/credentials")
# => {:ok, %{"username" => "admin", "password" => "secret123"}}

# Fetch with bang! variant (raises on error)
credentials = AwsSecrets.get!("myapp/database/credentials")

# Store a secret (automatically encodes to JSON)
{:ok, _} = AwsSecrets.put("myapp/api-key", %{key: "abc123", env: "prod"})

# Create a new secret
{:ok, _} = AwsSecrets.create("myapp/new-secret", %{foo: "bar"})

# Delete a secret (with recovery window)
{:ok, _} = AwsSecrets.delete("myapp/old-secret", recovery_window_days: 7)

Configuration

Full Configuration Options

config :aws_secrets,
  # AWS region (required)
  region: "us-east-1",

  # Explicit credentials (optional - prefer IAM roles)
  access_key_id: "AKIAIOSFODNN7EXAMPLE",
  secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  session_token: "token",  # For temporary credentials

  # HTTP client configuration
  http_client: {AWS.HTTPClient.Finch, finch_name: MyApp.Finch},

  # Custom endpoint (for LocalStack, VPC endpoints)
  endpoint: "http://localhost:4566",

  # Caching
  cache_enabled: true,
  cache_ttl_ms: :timer.minutes(5),

  # JSON library
  json_library: Jason

Authentication Priority

Credentials are resolved in this order:

  1. Explicit config (:access_key_id, :secret_access_key)
  2. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
  3. AWS credentials file (~/.aws/credentials)
  4. IAM instance role (EC2, ECS, Lambda)
  5. Web Identity Token (EKS)

Recommendation: Use IAM roles whenever possible. Avoid hardcoding credentials.

API Reference

Reading Secrets

# Basic fetch
{:ok, value} = AwsSecrets.get("secret-name")

# Fetch specific version
{:ok, value} = AwsSecrets.get("secret-name", version_stage: "AWSPREVIOUS")
{:ok, value} = AwsSecrets.get("secret-name", version_id: "abc123")

# Fetch without JSON decoding
{:ok, raw} = AwsSecrets.get("secret-name", raw: true)

# Bypass cache
{:ok, fresh} = AwsSecrets.get("secret-name", bypass_cache: true)

# Batch fetch multiple secrets
{:ok, secrets} = AwsSecrets.batch_get(["secret-1", "secret-2", "secret-3"])
# => {:ok, %{"secret-1" => %{...}, "secret-2" => %{...}, "secret-3" => %{...}}}

# List all secrets (metadata only)
{:ok, list} = AwsSecrets.list()

# Get secret metadata
{:ok, info} = AwsSecrets.describe("secret-name")

Writing Secrets

# Update existing secret
{:ok, result} = AwsSecrets.put("secret-name", %{username: "admin", password: "new-pass"})

# Create new secret
{:ok, result} = AwsSecrets.create("new-secret", %{api_key: "xyz123"},
  description: "API credentials",
  tags: [%{"Key" => "Environment", "Value" => "production"}]
)

# Delete secret (with recovery window)
{:ok, _} = AwsSecrets.delete("old-secret", recovery_window_days: 7)

# Force delete (immediate, cannot be undone!)
{:ok, _} = AwsSecrets.delete("temp-secret", force_delete: true)

# Restore deleted secret (during recovery window)
{:ok, _} = AwsSecrets.restore("accidentally-deleted")

Utilities

# Generate a random password
{:ok, password} = AwsSecrets.generate_password(length: 24)

# Invalidate cached secret (after rotation)
AwsSecrets.invalidate_cache("rotated-secret")

# Clear entire cache
AwsSecrets.clear_cache()

Error Handling

All functions return {:ok, result} or {:error, reason} tuples. Errors are structured types:

case AwsSecrets.get("missing-secret") do
  {:ok, value} ->
    use_value(value)

  {:error, %AwsSecrets.Error.NotFound{secret_id: id}} ->
    Logger.warning("Secret not found: #{id}")

  {:error, %AwsSecrets.Error.AccessDenied{}} ->
    Logger.error("Permission denied")

  {:error, %AwsSecrets.Error.DecryptionFailure{}} ->
    Logger.error("KMS key issue")

  {:error, error} ->
    Logger.error("Unexpected error: #{inspect(error)}")
end

Error Types

Error Description
NotFound Secret doesn't exist or was deleted
AccessDenied Insufficient IAM permissions
InvalidParameter Invalid parameter in request
ResourceExists Secret already exists (on create)
LimitExceeded Rate limit or quota exceeded
DecryptionFailure KMS key permission issue
NetworkError Connection or network issue
Unknown Unexpected error

Caching

Secrets are cached by default to reduce API calls and latency:

# Configure cache TTL
config :aws_secrets,
  cache_enabled: true,
  cache_ttl_ms: :timer.minutes(5)

# Invalidate after rotation
AwsSecrets.invalidate_cache("rotated-secret")

# Bypass cache for a single request
AwsSecrets.get("secret", bypass_cache: true)

# Disable caching entirely
config :aws_secrets, cache_enabled: false

Note: Cache keys use a tuple format internally {secret_id, version_stage, version_id, region} to handle secret IDs with special characters (like ARNs containing colons) and prevent cross-region cache collisions.

Security Considerations

Cache Security

The built-in ETS cache has important security implications:

  • In-memory storage: Cached secrets are stored in plaintext in memory
  • Process visibility: The ETS table uses :public access - any process in the same BEAM VM can read cached secrets
  • Crash dumps: Secrets may appear in BEAM crash dumps

Recommendations:

  1. Multi-tenant apps: Disable caching or use separate BEAM nodes per tenant
  2. Highly sensitive secrets: Bypass cache for root credentials, HSM keys, etc.
    AwsSecrets.get("hsm/root-key", bypass_cache: true)
  3. Production: Configure crash dump security
    export ERL_CRASH_DUMP_BYTES=0  # Disable crash dumps

See AwsSecrets.Cache module docs for detailed security guidance.

Credential Security

  • Prefer IAM roles over hardcoded credentials
  • Never commit credentials to version control
  • Use environment variables or Secrets Manager for AWS credentials
  • Configure least-privilege IAM policies for your application

Secret Redaction

AwsSecrets provides built-in protection against accidentally logging or exposing secrets.

Wrap Sensitive Values

Use wrap_sensitive: true to automatically wrap sensitive fields in a protective struct:

{:ok, creds} = AwsSecrets.get("myapp/database", wrap_sensitive: true)
# => {:ok, %{"username" => "admin", "password" => #Sensitive<[REDACTED]>}}

# Inspect is safe - secrets are hidden
IO.inspect(creds)
# %{"username" => "admin", "password" => #Sensitive<[REDACTED]>}

# Access the actual value explicitly
actual_password = AwsSecrets.unwrap(creds["password"])
# => "secret123"

This protects against secrets appearing in:

  • Logger output
  • Error reports (Sentry, Honeybadger, etc.)
  • Phoenix debug pages
  • IEx sessions
  • Crash dumps

Redact Maps for Logging

Use redact_map/1 to safely log secret data:

{:ok, creds} = AwsSecrets.get("myapp/database")

# DON'T do this - exposes secrets!
Logger.info("Credentials: #{inspect(creds)}")

# DO this instead
Logger.info("Credentials: #{inspect(AwsSecrets.redact_map(creds))}")
# => Credentials: %{"username" => "admin", "password" => "[REDACTED]"}

Configure Sensitive Keys

By default, keys containing password, secret, token, key, credential, etc. are considered sensitive. Customize with:

config :aws_secrets,
  sensitive_keys: ["password", "secret", "token", "api_key", "private_key"]

Manual Wrapping

Wrap individual values:

# Create a sensitive value
password = AwsSecrets.sensitive("super-secret")
# => #Sensitive<[REDACTED]>

# Safe to log
Logger.info("Password is: #{password}")
# => Password is: [REDACTED]

# Unwrap when needed
actual = AwsSecrets.unwrap(password)
# => "super-secret"

Telemetry

AwsSecrets emits telemetry events for observability:

# Attach a handler
:telemetry.attach_many(
  "aws-secrets-logger",
  [
    [:aws_secrets, :get, :stop],
    [:aws_secrets, :get, :exception],
    [:aws_secrets, :cache, :hit],
    [:aws_secrets, :cache, :miss]
  ],
  &MyApp.Telemetry.handle_event/4,
  nil
)

Events

Event Measurements Metadata
[:aws_secrets, :get, :start] system_time secret_id, cached
[:aws_secrets, :get, :stop] duration secret_id, cached
[:aws_secrets, :get, :exception] duration secret_id, kind, reason
[:aws_secrets, :cache, :hit] count secret_id
[:aws_secrets, :cache, :miss] count secret_id

Local Development with LocalStack

# config/dev.exs
config :aws_secrets,
  endpoint: "http://localhost:4566",
  region: "us-east-1",
  access_key_id: "test",
  secret_access_key: "test"
# Start LocalStack
docker run -d -p 4566:4566 localstack/localstack

# Create a test secret
aws --endpoint-url=http://localhost:4566 secretsmanager create-secret \
  --name test-secret \
  --secret-string '{"username":"admin","password":"secret"}'

Integrating with Existing Systems

Phoenix Application (Full Setup)

1. Add to supervision tree (lib/my_app/application.ex):

defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # HTTP client for AWS (start before AwsSecrets.Cache)
      {Finch, name: MyApp.Finch},
      # Secrets cache (optional but recommended)
      AwsSecrets.Cache,
      # Your repo (will fetch credentials from Secrets Manager)
      MyApp.Repo,
      # Phoenix endpoint
      MyAppWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

2. Configure in config/runtime.exs:

import Config

config :aws_secrets,
  region: System.get_env("AWS_REGION", "us-east-1"),
  http_client: {AWS.HTTPClient.Finch, finch_name: MyApp.Finch},
  cache_ttl_ms: :timer.minutes(5)

# For local development with LocalStack
if config_env() == :dev do
  config :aws_secrets,
    endpoint: "http://localhost:4566",
    access_key_id: "test",
    secret_access_key: "test"
end

Ecto Repository with Dynamic Credentials

defmodule MyApp.Repo do
  use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres

  def init(_type, config) do
    # Fetch database credentials from Secrets Manager
    secret_name = "myapp/#{config_env()}/database"

    case AwsSecrets.get(secret_name) do
      {:ok, secrets} ->
        config =
          Keyword.merge(config,
            username: secrets["username"],
            password: secrets["password"],
            hostname: secrets["host"],
            port: secrets["port"] || 5432,
            database: secrets["database"]
          )

        {:ok, config}

      {:error, error} ->
        raise "Failed to fetch database credentials: #{inspect(error)}"
    end
  end

  defp config_env, do: Application.get_env(:my_app, :env, :dev)
end

Centralized Secrets Module

Create a module to centralize secret access with compile-time safety:

defmodule MyApp.Secrets do
  @moduledoc """
  Centralized access to application secrets.
  """

  @doc "Database credentials for the given environment"
  def database(env \\ config_env()) do
    AwsSecrets.get!("myapp/#{env}/database")
  end

  @doc "Stripe API keys"
  def stripe do
    AwsSecrets.get!("myapp/stripe")
  end

  @doc "SendGrid API key"
  def sendgrid_api_key do
    %{"api_key" => key} = AwsSecrets.get!("myapp/sendgrid")
    key
  end

  @doc "JWT signing secret"
  def jwt_secret do
    %{"secret" => secret} = AwsSecrets.get!("myapp/jwt")
    secret
  end

  defp config_env, do: Application.get_env(:my_app, :env, :dev)
end

Usage:

# In your code
stripe_keys = MyApp.Secrets.stripe()
Stripe.API.key(stripe_keys["secret_key"])

GenServer for Auto-Refreshing Secrets

For secrets that rotate frequently, use a GenServer to refresh periodically:

defmodule MyApp.SecretRefresher do
  use GenServer
  require Logger

  @refresh_interval :timer.minutes(4)  # Refresh before cache TTL expires

  def start_link(opts) do
    secret_id = Keyword.fetch!(opts, :secret_id)
    GenServer.start_link(__MODULE__, secret_id, name: via_tuple(secret_id))
  end

  def get(secret_id) do
    GenServer.call(via_tuple(secret_id), :get)
  end

  @impl true
  def init(secret_id) do
    # Fetch immediately on start
    case AwsSecrets.get(secret_id, bypass_cache: true) do
      {:ok, value} ->
        schedule_refresh()
        {:ok, %{secret_id: secret_id, value: value}}

      {:error, error} ->
        {:stop, error}
    end
  end

  @impl true
  def handle_call(:get, _from, state) do
    {:reply, {:ok, state.value}, state}
  end

  @impl true
  def handle_info(:refresh, state) do
    case AwsSecrets.get(state.secret_id, bypass_cache: true) do
      {:ok, value} ->
        Logger.debug("Refreshed secret: #{state.secret_id}")
        schedule_refresh()
        {:noreply, %{state | value: value}}

      {:error, error} ->
        Logger.warning("Failed to refresh secret #{state.secret_id}: #{inspect(error)}")
        # Retry sooner on failure
        Process.send_after(self(), :refresh, :timer.seconds(30))
        {:noreply, state}
    end
  end

  defp schedule_refresh do
    Process.send_after(self(), :refresh, @refresh_interval)
  end

  defp via_tuple(secret_id), do: {:via, Registry, {MyApp.SecretRegistry, secret_id}}
end

Add to supervision tree:

children = [
  {Registry, keys: :unique, name: MyApp.SecretRegistry},
  {MyApp.SecretRefresher, secret_id: "myapp/rotating-api-key"},
  # ...
]

Oban Background Jobs

Access secrets in background workers:

defmodule MyApp.Workers.SendEmailWorker do
  use Oban.Worker, queue: :emails

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"to" => to, "subject" => subject, "body" => body}}) do
    # Secrets are cached, so this is fast
    %{"api_key" => api_key} = AwsSecrets.get!("myapp/sendgrid")

    case Sendgrid.send(api_key, to, subject, body) do
      :ok -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

Phoenix LiveView

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    # Fetch third-party API credentials
    {:ok, api_config} = AwsSecrets.get("myapp/analytics-api")

    socket =
      socket
      |> assign(:api_endpoint, api_config["endpoint"])
      |> assign(:analytics_data, nil)

    if connected?(socket) do
      send(self(), :load_analytics)
    end

    {:ok, socket}
  end

  @impl true
  def handle_info(:load_analytics, socket) do
    # Use the cached credentials
    data = fetch_analytics(socket.assigns.api_endpoint)
    {:noreply, assign(socket, :analytics_data, data)}
  end
end

Release Configuration (rel/env.sh.eex)

For production releases, ensure AWS credentials are available:

#!/bin/sh

# AWS credentials (prefer IAM roles over env vars)
export AWS_REGION="${AWS_REGION:-us-east-1}"

# Optional: explicit credentials (not recommended for production)
# export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
# export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"

Testing with Mocks

In tests, you can bypass real AWS calls:

# test/support/test_helper.ex
defmodule MyApp.TestSecrets do
  def mock_secrets do
    %{
      "myapp/test/database" => %{
        "username" => "test",
        "password" => "test",
        "host" => "localhost",
        "database" => "myapp_test"
      },
      "myapp/stripe" => %{
        "secret_key" => "sk_test_xxx",
        "public_key" => "pk_test_xxx"
      }
    }
  end
end

# In your test
test "processes payment" do
  # Pre-populate cache with test values
  AwsSecrets.Cache.put(
    {"myapp/stripe", "AWSCURRENT", nil},
    MyApp.TestSecrets.mock_secrets()["myapp/stripe"]
  )

  # Your test code - will use cached value
  assert {:ok, _} = MyApp.Payments.charge(100)
end

Common Use Cases

Database Credentials at Runtime

# lib/my_app/repo.ex
defmodule MyApp.Repo do
  use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres

  def init(_type, config) do
    {:ok, secrets} = AwsSecrets.get("myapp/database/#{Mix.env()}")

    config = Keyword.merge(config, [
      username: secrets["username"],
      password: secrets["password"],
      hostname: secrets["host"],
      database: secrets["database"]
    ])

    {:ok, config}
  end
end

API Keys in Phoenix

# lib/my_app_web/controllers/api_controller.ex
defmodule MyAppWeb.ApiController do
  use MyAppWeb, :controller

  def call_external_api(conn, _params) do
    {:ok, %{"api_key" => key}} = AwsSecrets.get("myapp/external-api")

    # Use the key...
  end
end

IAM Permissions

Minimum required IAM policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "arn:aws:secretsmanager:*:*:secret:myapp/*"
    }
  ]
}

For full access (create, update, delete):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret",
        "secretsmanager:CreateSecret",
        "secretsmanager:UpdateSecret",
        "secretsmanager:DeleteSecret",
        "secretsmanager:ListSecrets",
        "secretsmanager:RestoreSecret"
      ],
      "Resource": "*"
    }
  ]
}

License

MIT License. See LICENSE for details.

About

Manage AWS secrets.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published