Steve Kinney

Accessing Secrets from Lambda

You know how to store secrets in Parameter Store and Secrets Manager. Now you need to read them from a Lambda function. The pattern is straightforward: make an SDK call during initialization, cache the result in a module-level variable, and reuse it across invocations. This is the same init-time pattern you used for environment variables in Lambda Environment Variables—the difference is that the value comes from an API call instead of process.env.

If you want AWS’s official version of the runtime side of this pattern, the AWS Secrets Manager overview and the Lambda environment variables guide are the references worth keeping open.

flowchart LR
    Cold["Cold start or expired cache"] --> Fetch["Fetch parameter or secret with the SDK"]
    Fetch --> Cache["Store value in a module-level cache"]
    Cache --> Handler["Handler uses cached value"]
    Handler --> Warm{"Warm invocation and TTL still valid?"}
    Warm -- "Yes" --> Handler
    Warm -- "No" --> Fetch

Reading from Parameter Store

Install the SSM client package in your Lambda project:

npm install @aws-sdk/client-ssm

Here’s a Lambda function that reads an API key from Parameter Store at init time:

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

const ssm = new SSMClient({});

let apiKey: string | undefined;

const loadConfig = async () => {
  if (apiKey) return;

  const response = await ssm.send(
    new GetParameterCommand({
      Name: '/my-frontend-app/production/api-key',
      WithDecryption: true,
    }),
  );
Note WithDecryption: true is required for SecureString parameters. Without it, you get encrypted ciphertext.
  apiKey = response.Parameter?.Value;
};

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  await loadConfig();

  if (!apiKey) {
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Failed to load API key' }),
    };
  }

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Configuration loaded', keyPrefix: apiKey.slice(0, 4) }),
  };
};

The loadConfig function runs on the first invocation. On subsequent warm invocations, apiKey is already set and the SDK call is skipped. This means you make one API call per cold start, not one per request.

Reading from Secrets Manager

Install the Secrets Manager client package:

npm install @aws-sdk/client-secrets-manager
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const secretsManager = new SecretsManagerClient({});

let stripeConfig: { apiKey: string; webhookSecret: string } | undefined;

const loadSecrets = async () => {
  if (stripeConfig) return;

  const response = await secretsManager.send(
    new GetSecretValueCommand({
      SecretId: '/my-frontend-app/production/stripe-key',
    }),
  );

  if (!response.SecretString) {
    throw new Error('Secret value is empty');
  }

  stripeConfig = JSON.parse(response.SecretString);
Note Secrets Manager returns the decrypted value automatically. No WithDecryption flag needed.};

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  await loadSecrets();

  if (!stripeConfig) {
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Failed to load Stripe configuration' }),
    };
  }

  // Use stripeConfig.apiKey and stripeConfig.webhookSecret in your business logic
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Stripe configuration loaded' }),
  };
};

IAM Permissions

Your Lambda function’s execution role needs permission to read the specific parameters or secrets it uses. Recall from Lambda Execution Roles and Permissions that the execution role controls what AWS services the function can access.

For Parameter Store

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
      "Resource": "arn:aws:ssm:us-east-1:123456789012:parameter/my-frontend-app/production/*"
    },
    {
      "Effect": "Allow",
      "Action": ["kms:Decrypt"],
      "Resource": "arn:aws:kms:us-east-1:123456789012:alias/aws/ssm"
    }
  ]
}

The first statement grants access to all parameters under the /my-frontend-app/production/ path. The second statement grants permission to decrypt SecureString parameters using the AWS-managed KMS key. If you used a customer-managed KMS key, replace the resource ARN with your key’s ARN.

The parameter ARN in IAM policies does not include a leading slash before the parameter name. The parameter name /my-frontend-app/production/api-key has the ARN arn:aws:ssm:us-east-1:123456789012:parameter/my-frontend-app/production/api-key—note the single slash between parameter and my-frontend-app. This trips people up because the parameter name starts with / but the ARN path doesn’t double it.

For Secrets Manager

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue"],
      "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:/my-frontend-app/production/*"
    }
  ]
}

Secrets Manager handles decryption internally when you call GetSecretValue, so you don’t need a separate kms:Decrypt permission—unless you used a customer-managed KMS key. In that case, add a kms:Decrypt statement for that key.

Attaching the Policy

Attach these permissions to your Lambda execution role using an inline policy:

aws iam put-role-policy \
  --role-name my-frontend-app-lambda-role \
  --policy-name parameter-store-access \
  --policy-document file://parameter-store-policy.json \
  --output json

Or create a managed policy and attach it—the approach you learned in Writing Your First IAM Policy. Either way, the principle of least privilege applies: grant access to the specific parameters your function needs, not to all parameters in your account.

Caching Strategies

The init-time fetch pattern shown above works well for secrets that don’t change while the function is running. But if you use Secrets Manager’s automatic rotation, a secret could change while your Lambda execution environment is still warm. The cached value becomes stale.

Here’s a caching pattern with a time-to-live (TTL):

import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

const ssm = new SSMClient({});

let cachedValue: string | undefined;
let cacheExpiry = 0;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

const getParameter = async (name: string): Promise<string> => {
  const now = Date.now();

  if (cachedValue && now < cacheExpiry) {
    return cachedValue;
  }

  const response = await ssm.send(
    new GetParameterCommand({
      Name: name,
      WithDecryption: true,
    }),
  );

  if (!response.Parameter?.Value) {
    throw new Error(`Parameter ${name} not found`);
  }

  cachedValue = response.Parameter.Value;
  cacheExpiry = now + CACHE_TTL_MS;
Note After 5 minutes, the next invocation re-fetches the parameter. This keeps the cache fresh without calling the API on every request.
  return cachedValue;
};

A 5-minute TTL means your function re-fetches the secret at most once every 5 minutes per execution environment. For most applications, I’ve found this to be a good balance between freshness and API call cost.

The AWS Parameters and Secrets Lambda Extension

AWS provides a Lambda extension that handles caching for you. Instead of making SDK calls from your code, you add a Lambda layer that runs a local HTTP cache. Your function retrieves parameters by calling localhost:2773 instead of the SSM or Secrets Manager API.

Add the extension layer to your function:

EXTENSION_ARN=$(aws ssm get-parameter \
  --name "/aws/service/aws-parameters-and-secrets-lambda-extension/x86/latest" \
  --query "Parameter.Value" \
  --region us-east-1 \
  --output text)

aws lambda update-function-configuration \
  --function-name my-frontend-app-api \
  --layers "$EXTENSION_ARN" \
  --region us-east-1 \
  --output json

AWS now publishes the latest extension ARN as a public Systems Manager parameter. Use the x86/latest path above for x86_64 functions and /aws/service/aws-parameters-and-secrets-lambda-extension/arm64/latest for arm64 functions. That keeps the lesson correct even when AWS revs the layer version again.

Then retrieve parameters via HTTP:

const getParameterFromExtension = async (name: string): Promise<string> => {
  const response = await fetch(
    `http://localhost:2773/systemsmanager/parameters/get?name=${encodeURIComponent(name)}&withDecryption=true`,
    {
      headers: {
        'X-Aws-Parameters-Secrets-Token': process.env.AWS_SESSION_TOKEN ?? '',
      },
    },
  );

  const data = await response.json();
  return data.Parameter.Value;
};

For Secrets Manager:

const getSecretFromExtension = async (secretId: string): Promise<string> => {
  const response = await fetch(
    `http://localhost:2773/secretsmanager/get?secretId=${encodeURIComponent(secretId)}`,
    {
      headers: {
        'X-Aws-Parameters-Secrets-Token': process.env.AWS_SESSION_TOKEN ?? '',
      },
    },
  );

  const data = await response.json();
  return data.SecretString;
};

The extension caches values with a configurable TTL (default 300 seconds). You control the TTL with the SSM_PARAMETER_STORE_TTL and SECRETS_MANAGER_TTL environment variables on the function.

The extension approach trades SDK dependencies for HTTP calls. It’s useful when you want caching without writing cache logic, or when you want to keep your deployment package small by not bundling the SSM or Secrets Manager SDK packages. For simple use cases with a few secrets, the direct SDK approach is perfectly fine.

Putting It All Together

Here’s a complete Lambda function that reads a DynamoDB table name from an environment variable (non-sensitive, doesn’t change), an API key from Parameter Store (sensitive, rarely changes), and uses both:

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

const ssm = new SSMClient({});
const tableName = process.env.TABLE_NAME;

let apiKey: string | undefined;

const loadSecrets = async () => {
  if (apiKey) return;

  const response = await ssm.send(
    new GetParameterCommand({
      Name: '/my-frontend-app/production/api-key',
      WithDecryption: true,
    }),
  );

  apiKey = response.Parameter?.Value;
};

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  await loadSecrets();

  if (!tableName || !apiKey) {
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Missing configuration' }),
    };
  }

  // Use tableName for DynamoDB operations, apiKey for third-party API calls
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: 'All configuration loaded',
      table: tableName,
    }),
  };
};

This pattern—environment variables for non-sensitive config, Parameter Store or Secrets Manager for sensitive values, init-time caching for both—is the standard approach for Lambda functions in production. You now have two services that solve similar problems. The next lesson provides a direct comparison between Parameter Store and Secrets Manager to help you decide which one to reach for in different scenarios.

Last modified on .