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.
- 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)
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
]
endAdd 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# config/runtime.exs
config :aws_secrets,
region: System.get_env("AWS_REGION", "us-east-1"),
http_client: {AWS.HTTPClient.Finch, finch_name: MyApp.Finch}# 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)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: JasonCredentials are resolved in this order:
- Explicit config (
:access_key_id,:secret_access_key) - Environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) - AWS credentials file (
~/.aws/credentials) - IAM instance role (EC2, ECS, Lambda)
- Web Identity Token (EKS)
Recommendation: Use IAM roles whenever possible. Avoid hardcoding credentials.
# 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")# 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")# 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()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 | 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 |
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: falseNote: 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.
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
:publicaccess - any process in the same BEAM VM can read cached secrets - Crash dumps: Secrets may appear in BEAM crash dumps
Recommendations:
- Multi-tenant apps: Disable caching or use separate BEAM nodes per tenant
- Highly sensitive secrets: Bypass cache for root credentials, HSM keys, etc.
AwsSecrets.get("hsm/root-key", bypass_cache: true)
- Production: Configure crash dump security
export ERL_CRASH_DUMP_BYTES=0 # Disable crash dumps
See AwsSecrets.Cache module docs for detailed security guidance.
- 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
AwsSecrets provides built-in protection against accidentally logging or exposing secrets.
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
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]"}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"]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"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
)| 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 |
# 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"}'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
end2. 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"
enddefmodule 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)
endCreate 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)
endUsage:
# In your code
stripe_keys = MyApp.Secrets.stripe()
Stripe.API.key(stripe_keys["secret_key"])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}}
endAdd to supervision tree:
children = [
{Registry, keys: :unique, name: MyApp.SecretRegistry},
{MyApp.SecretRefresher, secret_id: "myapp/rotating-api-key"},
# ...
]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
enddefmodule 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
endFor 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}"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# 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# 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
endMinimum 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": "*"
}
]
}MIT License. See LICENSE for details.