DEV Community

Cover image for Secure Secret Management for AWS Lambda Functions in TypeScript Monorepos
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

1

Secure Secret Management for AWS Lambda Functions in TypeScript Monorepos

🐙 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Image of Datadog

The Future of AI, LLMs, and Observability on Google Cloud

Datadog sat down with Google’s Director of AI to discuss the current and future states of AI, ML, and LLMs on Google Cloud. Discover 7 key insights for technical leaders, covering everything from upskilling teams to observability best practices

Learn More

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay