🐙 GitHub
Introduction
When building AWS Lambda functions, you'll inevitably need to handle sensitive information. While environment variables might seem convenient, they present a significant security risk as they can be accidentally exposed through logs or debugging tools. In this post, I'll walk you through a more secure approach: storing your secrets in AWS Secrets Manager and efficiently sharing them across multiple Lambda functions in a TypeScript monorepo. All the source code for this implementation can be found in my open-source project RadzionKit.
Cost Optimization with AWS Secrets Manager
AWS Secrets Manager pricing is based on the number of secrets stored rather than the amount of data in each secret. This means a single secret can contain multiple key-value pairs at no additional cost. For monorepos with several services that require access to secrets, this pricing model allows us to optimize costs by creating just one secret with multiple keys. To leverage this approach effectively, we can implement a dedicated package in our monorepo that centralizes secret access for all services.
{
"name": "@product/secrets",
"version": "0.0.1",
"private": true,
"scripts": {
"set-secrets": "npx tsx scripts/setSecrets.ts"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.758.0"
}
}
Implementing Type-Safe Secret Retrieval
This package will export a strongly-typed getSecret
function that retrieves secret values by name. By leveraging TypeScript's type system, we can define all possible secret names as a union type, providing compile-time validation that prevents typos and ensures we're requesting secrets that actually exist.
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { assertField } from "@lib/utils/record/assertField"
import { getEnvVar } from "./getEnvVar"
export const secretNames = [
"googleClientSecret",
"facebookClientSecret",
"paddleApiKey",
"telegramBotToken",
"emailSecret",
"jwtSecret",
] as const
type SecretName = (typeof secretNames)[number]
const getSecrets = async () => {
const client = new SecretsManagerClient({})
const command = new GetSecretValueCommand({ SecretId: getEnvVar("SECRETS") })
const { SecretString } = await client.send(command)
return shouldBePresent(SecretString)
}
export const getSecret = async <T = string>(name: SecretName): Promise<T> => {
const secrets = await getSecrets()
return assertField(JSON.parse(secrets), name)
}
To retrieve a secret, we first initialize the SecretsManagerClient and use the GetSecretValueCommand with our secret ID (stored in an environment variable). This returns a stringified JSON object containing all our secret key-value pairs. We then parse this JSON and use our assertField
utility to extract the specific secret value we need, ensuring type safety throughout the process.
Type-Safe Environment Variable Access
For each service in our monorepo, I implement a dedicated getEnvVar
function that serves as a single source of truth for environment variables. This approach centralizes environment variable access, provides type safety through TypeScript's union types, and fails fast with clear error messages when required variables are missing. Even for services that only need one environment variable, this pattern ensures consistency across the codebase and makes future additions straightforward.
type VariableName = "SECRETS"
export const getEnvVar = <T extends string>(name: VariableName): T => {
const value = process.env[name]
if (!value) {
throw new Error(`Missing ${name} environment variable`)
}
return value as T
}
Updating Secrets Safely
To complement our secret retrieval functionality, we need a reliable way to update our secrets in AWS Secrets Manager. The following script handles this task when run locally. What makes it particularly useful is how it iterates over all defined secret names and verifies each corresponding environment variable exists before proceeding. If any required secret is missing, the script fails immediately with a clear error message, preventing partial updates that could leave your application in an inconsistent state.
import {
SecretsManagerClient,
PutSecretValueCommand,
} from "@aws-sdk/client-secrets-manager"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { recordFromKeys } from "@lib/utils/record/recordFromKeys"
import { secretNames } from ".."
import { getEnvVar } from "../getEnvVar"
const secretsManager = new SecretsManagerClient({})
async function setSecrets() {
const secrets = recordFromKeys(secretNames, (key) =>
shouldBePresent(process.env[`SECRET_${key}`]),
)
const command = new PutSecretValueCommand({
SecretId: getEnvVar("SECRETS"),
SecretString: JSON.stringify(secrets),
})
await secretsManager.send(command)
console.log("Successfully updated secrets in AWS Secrets Manager")
}
setSecrets().catch(console.error)
Infrastructure as Code with Terraform
After applying this Terraform configuration, you can run your set-secrets
script with the appropriate environment variables to populate the secret with actual values.
resource "aws_secretsmanager_secret" "secrets" {
name = "tf-${var.name}"
}
To grant Lambda functions access to our secrets, we need to reference both the ARN and name of the secret in our IAM policies. Let's add an outputs.tf
file to our Terraform configuration to expose these values:
output "secrets_arn" {
value = aws_secretsmanager_secret.secrets.arn
}
output "secrets_name" {
value = aws_secretsmanager_secret.secrets.name
}
Configuring IAM Permissions
Now that we've defined our secret in Terraform and exposed its ARN and name, we need to grant our Lambda functions permission to access it. Let's create an IAM policy that specifically allows the GetSecretValue action on our secret resource:
resource "aws_iam_policy" "secrets" {
name = "tf-${var.name}-secrets"
path = "/"
policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "secretsmanager:GetSecretValue",
"Resource": "${var.secrets_arn}",
"Effect": "Allow"
}
]
}
EOF
}
resource "aws_iam_role_policy_attachment" "secrets" {
role = aws_iam_role.api.name
policy_arn = aws_iam_policy.secrets.arn
}
Conclusion
This approach provides a secure, cost-effective way to manage secrets across Lambda functions while leveraging TypeScript's type safety to prevent errors and improve developer experience.
Top comments (0)