How-to › Operate integrations in production

How to load secrets in Lambda without a fetch per invocation#

Read an API key from Secrets Manager once per execution environment, not per invocation, through the extension, the SDK, or sekreto, and count the calls.

Audience
API consumer
Level
intermediate
Topic
Load credentials and secrets
Languages
TypeScript and Python
Verified

Every invocation of your function calls Secrets Manager for the same API key. The call adds a round trip and a charge to each request, and the GetSecretValue count in CloudWatch climbs with your traffic. A throttling error from Secrets Manager then becomes a function that cannot find its own credentials, on the busiest morning of the month.

What you get

You will end up with a handler that reads its key once per execution environment and serves every warm invocation from memory. A count from a local Secrets Manager stand-in proves it. This is for you if a Lambda function calls a third-party API with a key it should not carry in its configuration.

Short answer

Fetch the secret in module scope, outside the handler, so the fetch runs once when Lambda initializes an execution environment and every warm invocation reuses it. Cache the promise with a TTL, and drop it on failure. Or add the AWS Parameters and Secrets Lambda Extension as a layer and read from its cache on localhost port 2773 with the session token as a header. Verify by counting GetSecretValue calls while invocations climb.

You will need

Node 22 or later, an execution role allowed to read the secret, and a secret in Secrets Manager. Verified 2026-09-25 against Node 22.22.2, @aws-sdk/client-secrets-manager 3.1140.0, and @voxgig/sekreto 0.3.0. Nothing on this page calls AWS. Secrets Manager and the extension are local stand-ins that speak the same HTTP, and every count comes from them.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AWS Parameters and Secrets Lambda ExtensionAny runtime, no SDK in the function, and a cache with a TTL set by environment variableA layer ARN per region to track, a second process in every environment, and a session token to forward on each requestThe function runs outside Lambda, or the cache must notice a rotation sooner than its TTL
AWS SDK with a module-scope cacheThe SDK is already in the bundle and the function only ever runs on LambdaA cache you write and test yourself, TTL and failure path included, and a handler tied to one vendor’s clientThe same handler has to run somewhere without AWS credentials in the environment
sekretoThe handler should name a secret without naming the store, and run unchanged against a vault or a .env file elsewhereA dependency in the bundle, an in-tree signer instead of the AWS SDK, and a cache with no TTL until you call refreshYou want the extension’s cache and TTL without code, or you already depend on the AWS SDK

Voxgig maintains sekreto. This page compares it with the AWS Parameters and Secrets Lambda Extension and with the AWS SDK behind a cache you write.

The extension moves the cache out of your code and into a process AWS ships, and charges you a layer to keep current. The SDK cache is twenty lines you own, which is the point and the cost. sekreto’s case is a handler that reads billco.key and does not know it is reading Secrets Manager. Its cache holds a value until you tell it otherwise, so the TTL is still yours to write.

Fetch once, in module scope

Lambda runs a module’s top level once, when it initializes an execution environment, and then reuses that environment for the invocations that follow. AWS’s own guidance says to initialize SDK clients outside the handler for that reason, and a secret is the same shape of thing.

const client = new SecretsManagerClient({
  region: process.env.AWS_REGION,
  endpoint: process.env.SECRETS_ENDPOINT,
})
const TTL_MS = Number(process.env.SECRET_TTL_SECONDS ?? 300) * 1000
// A typo such as 5m is NaN, which would turn the cache off without a word. Fail the cold start.
if (!(TTL_MS >= 0)) throw new Error(`SECRET_TTL_SECONDS is ${process.env.SECRET_TTL_SECONDS}, not a number of seconds`)

let cached // { promise, expires }

// Cache the promise, not the value, so two overlapping calls share one fetch. Drop it on
// failure, so an outage is retried on the next invocation instead of being served for the TTL.
// The catch evicts only its own entry: a slow failure must not discard a newer fetch.
export function loadSecret() {
  if (cached && Date.now() < cached.expires) return cached.promise
  const entry = { expires: Date.now() + TTL_MS }
  entry.promise = client.send(new GetSecretValueCommand({ SecretId: 'billco' }))
    .then((res) => JSON.parse(res.SecretString))
    .catch((err) => { if (cached === entry) cached = undefined; throw err })
  cached = entry
  return entry.promise
}

Two details carry the weight. The cache holds the promise, so a fetch that is still in flight is shared rather than repeated. And a rejected promise is evicted in the catch, because a cache that remembers an outage serves that outage to every invocation until the TTL passes. The endpoint option is how the stand-in gets in; leave it unset in production and the SDK resolves the regional endpoint itself.

The TTL is what lets a rotation land: an environment that cached the old version keeps using it until the TTL expires or the environment is recycled.

Read from the extension on localhost

The extension is a layer that runs beside your function and answers HTTP on localhost, port 2773 unless PARAMETERS_SECRETS_EXTENSION_HTTP_PORT says otherwise. It calls Secrets Manager on a miss and serves the cache for SECRETS_MANAGER_TTL seconds, 300 by default, for up to 1,000 entries. Every request has to carry the function’s session token in a header, which is how the extension knows the request came from inside the environment.1

export async function loadSecret() {
  const res = await fetch(`http://localhost:${PORT}/secretsmanager/get?secretId=billco`, {
    // The session token proves the request comes from inside the function's own environment.
    headers: { 'X-Aws-Parameters-Secrets-Token': process.env.AWS_SESSION_TOKEN },
  })
  if (!res.ok) throw new Error(`extension answered ${res.status}`)
  const { SecretString } = await res.json()
  return JSON.parse(SecretString)
}

No SDK, and the same six lines in Python with urllib, which is the example the Lambda guide gives. That guide also carries the note this code ignores. Under SnapStart AWS_SESSION_TOKEN is not set, so a function restored from a snapshot reads the token from an SDK’s credential provider chain, inside the handler.

The layer is an ARN with a version on the end and a different account per region.2 The execution role needs one statement, whichever approach reads the secret:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:billco-AbCdEf"
    }
  ]
}

A secret encrypted with a customer managed key needs kms:Decrypt on that key as well, which the IAM examples for Secrets Manager show as a second statement.

Name the secret, not the store

sekreto’s awssecrets provider signs its own requests, so the bundle carries no AWS SDK, and the handler asks for a name.

const secrets = new Sekreto({
  plugins: [awssecrets],
  providers: [{ kind: 'awssecrets', region: process.env.AWS_REGION, addr: process.env.SECRETS_ENDPOINT }],
})

// A Sekreto keeps every value it resolves for as long as the instance lives, so a module-scope
// cache around it would cache a cache. What it lacks is a TTL: refresh() drops what it holds,
// and the next get asks the store again.
const TTL_MS = Number(process.env.SECRET_TTL_SECONDS ?? 300) * 1000
// A typo such as 5m is NaN, and a NaN deadline never arrives, so a rotation would never land.
if (!(TTL_MS >= 0)) throw new Error(`SECRET_TTL_SECONDS is ${process.env.SECRET_TTL_SECONDS}, not a number of seconds`)
let refreshAt = Date.now() + TTL_MS

export async function loadSecret() {
  if (Date.now() >= refreshAt) {
    secrets.refresh()
    refreshAt = Date.now() + TTL_MS
  }
  return secrets.get('billco.key')
}

billco.key reads the secret named billco and takes the key field of its JSON value, which is the AWS idiom of one JSON map per secret. Region and credentials come from the config first and the standard AWS_* variables second, so a Lambda environment needs nothing added. The addr option exists for the stand-in; the provider refuses plain HTTP to anything but loopback.

The first draft of this file wrapped get in the same promise cache as the SDK version, and the rotation test failed: the value never changed. sekreto caches what it resolves, by store and name, for the life of the instance, and its documentation says so under cache and refresh. The cache is the benefit, the missing TTL is the cost, and the preceding lines are the TTL.

Check it worked

Three execution environments, ten invocations each, for every approach, against the stand-ins. Then a rotation, and a look at what each warm environment serves.

node harness.mjs
node 22.22.2, @aws-sdk/client-secrets-manager 3.1140.0, @voxgig/sekreto 0.3.0, against local stand-ins

3 execution environments, 10 invocations each, cache TTL 5s
  approach                                invocations  GetSecretValue calls
  AWS SDK, no cache                       30           30
  AWS SDK, module-scope cache             30           3
  extension, cache on localhost           30           3
  sekreto, its own cache, refresh at TTL  30           3

rotate the secret, then invoke every warm environment again
  approach                                at once      after the TTL
  AWS SDK, no cache                       sk_live_v2   sk_live_v2
  AWS SDK, module-scope cache             sk_live_v1   sk_live_v2
  extension, cache on localhost           sk_live_v1   sk_live_v2
  sekreto, its own cache, refresh at TTL  sk_live_v1   sk_live_v2

Thirty invocations, three calls: one per environment, however many invocations each one serves. The row with no cache is the bill you are paying today. The rotation table is the price of the other three rows, the same for all of them: an old key for up to one TTL.

In production the same verification is two CloudWatch lines. Invocations in the AWS/Lambda namespace climbs with traffic, and CallCount in AWS/Usage for the GetSecretValue resource stays flat, moving only when environments start.3 A cache that works is a graph that stays flat while the other one climbs.

node --test secrets.test.mjs
1..1
# tests 10
# suites 1
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0

The suite covers the paths the demo does not print. A request without the token is refused, a failed fetch is not cached, and a TTL that is not a number fails the cold start.

When it goes wrong

The key stops working right after a rotation. Every cache on this page serves the old value until its TTL ends, 300 seconds by default for the extension. Lower SECRETS_MANAGER_TTL, or treat a 401 from the provider as the signal to drop the cache and fetch again.

The extension refuses every request. The header is missing, or it carries something other than the session token. Under SnapStart the token is not in AWS_SESSION_TOKEN at all, so read it from the credential provider chain inside the handler.

Every invocation fails for five minutes after a blip. The cache stored a rejected promise. Evict on failure, as loadSecret does, so the outage lasts as long as the outage.

The key is visible in the console. Somebody set it as an environment variable at deploy time. Lambda stores those encrypted at rest, and anyone allowed to call GetFunctionConfiguration gets them back in the response. Only a customer managed key with kms:Decrypt withheld hides them.4

When not to do this

Do not put the API key in an environment variable at deploy time to save the fetch. The twelve-factor habit of config in the environment was written for processes whose environment nobody else can list. A Lambda function’s configuration is an API response.

Do not set the TTL to zero to be safe. That is the first row of the table, thirty calls for thirty invocations, and the throttling error from the opening paragraph.

Do not adopt sekreto to get a cache. It has one, but its case is the handler that names a secret and not a store, so the same code reads a .env file on a laptop and Secrets Manager in production. A function that only ever runs on Lambda, with the AWS SDK already in the bundle, is cheaper with the SDK and twenty lines.

Last verified

Verified 2026-09-25 against Node 22.22.2, @aws-sdk/client-secrets-manager 3.1140.0, and @voxgig/sekreto 0.3.0. Every output block is what the command preceding it printed. Secrets Manager and the extension were local stand-ins on 127.0.0.1 with the same HTTP interface. No AWS account, function, or layer was used.

Footnotes

  1. The header is a proof of origin rather than a credential of its own. The extension documentation says the X-Aws-Parameters-Secrets-Token value is the function’s session token, and that for most functions it is provided in AWS_SESSION_TOKEN. It adds that Lambda does not set that variable in every initialization mode, SnapStart being its example. A header that says this request came from inside the building, checked by a process that also lives inside the building. ↩︎ Back to text

  2. A layer is an ARN per region with a version number on the end, and the table of them carries a date. On the day this page was checked, us-east-1 was at version 116 and us-east-2 at 133, each under a different account id. The Arm64 builds are separate layers, each with an ARN of its own. A function that moves region or architecture changes layer, and a function that does neither still watches a number that moves on its own. ↩︎ Back to text

  3. The count that proves the cache is not a Secrets Manager metric at all. Its monitoring guide sends you to Usage, By AWS Resource, in the CloudWatch console. There the AWS/Usage namespace publishes CallCount with Service, Type, and Resource dimensions, collected every minute and meant for watching quotas. The metric exists to warn you before you hit a limit, and it happens to be the only witness to how often your function asked. ↩︎ Back to text

  4. The environment variables page opens by recommending Secrets Manager instead of environment variables for API keys and tokens, then documents the variables in detail all the same. The encryption page adds that users without kms:Decrypt on a customer managed key cannot view or manage the variables, and that the default key needs no permission at all. By default, then, the variables are readable by whoever can read the function. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.