10 min readUpdated

How to Securely Use Secrets in AWS Lambda?

Environment variables are not the best way to use secrets in your AWS Lambda. I will tell you why and show you alternatives implemented in AWS CDK.


ℹ️

Update (October 2026): I added the two retrieval options AWS now documents for Lambda: the AWS Parameters and Secrets Lambda Extension and the Parameters utility of Powertools for AWS Lambda (TypeScript). The post now has a Secrets Manager example in CDK and explains why CDK’s secretValueFromJson().unsafeUnwrap() in an environment variable leaks the secret. I also fixed the middy example: cacheExpiry is in milliseconds, not seconds.

It is quite common to need a secret value of some kind in a Lambda function. Either for a database connection, a 3-rd party service, or whatever else. But how to securely use secrets in your Lambda?

In this post, I am going to tell you why environment variables are not the tool for the job and what the preferred way looks like.

I will also show you how to do better with AWS Secrets Manager, AWS CDK, and TypeScript.

Why Environment Variables Are Not the Best Solution for Accessing Secrets

I am sure I don’t have to tell you why it is a bad idea to put secrets in plain text into your code base. But why aren’t environment variables very secure either? AWS suggests not using environment variables for your secrets:

To increase security, we recommend that you use AWS Secrets Manager instead of environment variables to store database credentials and other sensitive information like API keys or authorization tokens.

(from Working with Lambda environment variables)

This tweet from Yan Cui (@theburningmonk) sums up the reasons pretty well:

There are malicious packages out there sending your environment variables somewhere else. Just google something like “npm package malicious environment variables” and you will find lists of malicious packages. Often they create packages with almost identical names than widely used packages. If you do a typo while installing a package you could end up sending your secrets to a bad actor. This is not an issue that is specific to AWS Lambda – but the solution I am going to show you is.

In addition to that, secrets stored in environment variables are part of the function configuration. Lambda encrypts them at rest, but everyone who can read that configuration (in the AWS console or via GetFunctionConfiguration) sees them in plain text. With other services (I will get to them in the next section) you can restrict who is able to see the secrets.

CDK’s unsafeUnwrap() Doesn’t Make It Safe

A common pattern in AWS CDK is to store the secret in Secrets Manager and resolve it into an environment variable:

const serviceASecret = Secret.fromSecretNameV2(this, 'ServiceASecret', 'dev/service-a');
new NodejsFunction(this, 'LeakyLambda', {
entry: path.join(__dirname, 'lambda', 'index.ts'),
environment: {
// ❌ the resolved secret ends up in plain text in the function configuration
SERVICE_A_API_KEY: serviceASecret.secretValueFromJson('apiKey').unsafeUnwrap(),
},
});

The synthesized template only contains a {{resolve:secretsmanager:...}} dynamic reference, so it looks safe. But CloudFormation resolves it during deployment and Lambda stores the resolved value as a regular environment variable. You are back to where you started – plus, rotating the secret doesn’t reach the function until the next deployment.

That is exactly why CDK makes you call a method with “unsafe” in its name. The SecretValue docs name this case explicitly:

To make sure secret values don’t accidentally end up in readable parts of your infrastructure definition (such as the environment variables of an AWS Lambda Function, where everyone who can read the function definition has access to the secret), using secret values directly is not allowed.

Pass the secret’s name or ARN as an environment variable instead and retrieve the value at runtime.

How to Securely Access Secrets in AWS Lambda?

Now that we know why using environment variables for secrets is a bad idea, how can we do better?

Secrets Manager or Parameter Store?

One solution has already been mentioned in the quote in the previous chapter: AWS Secrets Manager. Another popular solution is AWS Systems Manager Parameter Store with SecureString parameters. Both of them allow you to securely store secrets, restrict access via IAM, and retrieve them at runtime.

  • Secrets Manager is purpose-built for secrets: automatic rotation, cross-account access, and audit logging. It costs $0.40 per secret per month plus $0.05 per 10,000 API calls (pricing).
  • Parameter Store standard parameters come at no additional charge. AWS itself recommends Secrets Manager once you need rotation, cross-account access, or fine-grained audit logging.

I will use Secrets Manager for the main example and show Parameter Store with middy at the end.

Malicious packages could theoretically still get access to your secrets somehow but there is now more to it than just sending process.env somewhere. So this is not an 100% protection, but it is safer and as part of Defense in Depth a good practice.

It would be even safer to place the Lambda in a VPC and prevent it from connecting to the internet altogether. But that comes with other downsides and isn’t always applicable. (If you do, you need a VPC endpoint for Secrets Manager or Parameter Store.)

Caching: The Trade-off

You have different options on how to retrieve and use secrets – as always, all of them have different trade-offs:

  1. Retrieve secrets inside of the Lambda handler on every invocation
  2. Retrieve secrets during Lambda init phase (outside of handler) & cache them forever (= for the time of the Lambda execution context)
  3. Retrieve secrets once & cache them for a certain period, e.g. 5min

The trade-off you have to make is between performance & price (less API calls are cheaper and faster) and flexibility (you can change secrets immediately if you don’t cache them).

trade-offs retrieving secrets in AWS Lambda

Every execution environment keeps its own cache. So the number of API calls grows with the number of concurrent execution environments, not with the number of invocations. All options below implement option 3.

Secrets Manager + Lambda Example With AWS CDK

Let’s create a secret first. A JSON secret lets you keep related values together:

Terminal window
aws secretsmanager create-secret --name "dev/service-a" --secret-string '{"apiKey":"my-super-secret-api-key"}'

Note: I like to use a naming convention like [stage]/[service] in order to keep everything nice and organized and to be able to easily construct and access secrets for different environments.

In CDK, import the secret, pass its name (not its value) to the function, and grant read access:

export class LambdaRetrieveSecretsStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const serviceASecret = Secret.fromSecretNameV2(this, 'ServiceASecret', 'dev/service-a');
const secretsExampleLambda = new NodejsFunction(this, 'SecretsExampleLambda', {
entry: path.join(__dirname, 'lambda', 'index.ts'),
runtime: Runtime.NODEJS_24_X,
environment: {
SERVICE_A_SECRET_NAME: serviceASecret.secretName,
},
});
serviceASecret.grantRead(secretsExampleLambda);
}
}

grantRead allows secretsmanager:GetSecretValue (and DescribeSecret) on that secret. If the secret is encrypted with a customer managed KMS key, the function’s role also needs kms:Decrypt on that key.

Now there are two ways AWS recommends to retrieve the secret in the function. Both cache the value.

Option 1: Powertools for AWS Lambda Parameters Utility

The Parameters utility of Powertools for AWS Lambda (TypeScript) retrieves values from Secrets Manager, Parameter Store, AppConfig, or DynamoDB and caches them in memory:

Terminal window
npm install @aws-lambda-powertools/parameters @aws-sdk/client-secrets-manager

The handler:

import { getSecret } from '@aws-lambda-powertools/parameters/secrets';
type ServiceASecret = { apiKey: string };
const { SERVICE_A_SECRET_NAME } = process.env;
if (!SERVICE_A_SECRET_NAME) {
throw new Error('SERVICE_A_SECRET_NAME environment variable is not set');
}
export const handler = async () => {
const secret = await getSecret<ServiceASecret>(SERVICE_A_SECRET_NAME, {
transform: 'json',
maxAge: 300, // seconds
});
if (!secret) {
throw new Error(`Secret ${SERVICE_A_SECRET_NAME} not found`);
}
// use secret.apiKey – but never log it
};

The default cache duration is only 5 seconds. Set maxAge per call or the POWERTOOLS_PARAMETERS_MAX_AGE environment variable for all calls. transform: 'json' parses the JSON secret for you.

This is my default for TypeScript functions: it is just a library, it is easy to unit test, and the same API works for Parameter Store (getParameter from @aws-lambda-powertools/parameters/ssm).

Option 2: AWS Parameters and Secrets Lambda Extension

The AWS Parameters and Secrets Lambda Extension is an AWS-managed Lambda layer. It runs a local HTTP server (port 2773 by default) next to your function, fetches secrets and parameters using the function’s role, and caches them. It works with every runtime and doesn’t add a dependency to your code.

CDK has built-in support for it via the paramsAndSecrets property, which picks the right layer for your region and architecture:

const secretsExampleLambda = new NodejsFunction(this, 'SecretsExampleLambda', {
entry: path.join(__dirname, 'lambda', 'index.ts'),
runtime: Runtime.NODEJS_24_X,
environment: {
SERVICE_A_SECRET_NAME: serviceASecret.secretName,
},
paramsAndSecrets: ParamsAndSecretsLayerVersion.fromVersion(ParamsAndSecretsVersions.V1_0_103, {
secretsManagerTtl: Duration.minutes(5),
}),
});
serviceASecret.grantRead(secretsExampleLambda);

The handler requests the secret from localhost. The X-Aws-Parameters-Secrets-Token header is required and must contain the function’s session token:

const { SERVICE_A_SECRET_NAME, AWS_SESSION_TOKEN } = process.env;
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': AWS_SESSION_TOKEN! } },
);
if (!response.ok) {
throw new Error(`Failed to retrieve secret: ${response.status}`);
}
const { SecretString } = (await response.json()) as { SecretString: string };
return SecretString;
};
export const handler = async () => {
const { apiKey } = JSON.parse(await getSecretFromExtension(SERVICE_A_SECRET_NAME!));
// use apiKey
};

A few things to know:

  • The default and maximum TTL is 300 seconds (SECRETS_MANAGER_TTL, SSM_PARAMETER_STORE_TTL). The extension doesn’t detect changes before the TTL expires.
  • For Parameter Store, use /systemsmanager/parameters/get?name=...&withDecryption=true.
  • AWS_SESSION_TOKEN isn’t set in every initialization mode, e.g. with SnapStart. AWS recommends reading the token from the SDK’s credential provider chain inside the handler in that case.

Option 3: middy With Parameter Store

The original version of this post used middy with Parameter Store. That still works. You can find the code in my JannikWempe/lambda-retrieve-secrets repository.

Create the parameter:

Terminal window
aws ssm put-parameter --name "/dev/service-a/secret-key" --type "SecureString" --value "my-super-secret-secret"

In CDK, reference it with StringParameter.fromSecureStringParameterAttributes(this, 'ServiceASecretKey', { parameterName: '/dev/service-a/secret-key' }), pass parameterName as an environment variable, and call grantRead on it – just like with the secret above.

The ssm middleware retrieves and caches the decrypted parameter:

import middy from '@middy/core';
import ssm from '@middy/ssm';
const { SERVICE_A_SECRET_KEY_PARAMETER_NAME } = process.env;
if (!SERVICE_A_SECRET_KEY_PARAMETER_NAME) {
throw new Error('SERVICE_A_SECRET_KEY_PARAMETER_NAME environment variable is not set');
}
export const handler = middy()
.use(
ssm({
fetchData: {
SERVICE_A_SECRET_KEY: SERVICE_A_SECRET_KEY_PARAMETER_NAME,
},
cacheKey: 'service-a-secret-key',
cacheExpiry: 5 * 60 * 1000, // 5min – in milliseconds!
setToContext: true,
}),
)
.handler(async (_event, context) => {
// use context.SERVICE_A_SECRET_KEY – but never log it
});

fetchData maps the name under which the value is made available to the parameter name. setToContext: true puts the secret on the Lambda context – it will not end up in the environment variables. middy also has a secrets-manager middleware.

Conclusion

There are safer ways to use secrets in your AWS Lambda than by using environment variables – and resolving a secret into an environment variable with CDK’s unsafeUnwrap() doesn’t change that. Store secrets in Secrets Manager (or Parameter Store), pass only the name to your function, and retrieve the value at runtime with Powertools, the Parameters and Secrets Lambda Extension, or middy. Cache it to keep latency and cost low. You can easily rotate secrets in a central place and configure permissions only showing the secrets to authorized people. From now on, you should not use environment variables for secrets.