# 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.

Source: https://voxgig.com/howto/cache-lambda-secrets-across-invocations

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AWS Parameters and Secrets Lambda Extension](https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html) | Any runtime, no SDK in the function, and a cache with a TTL set by environment variable | A layer ARN per region to track, a second process in every environment, and a session token to forward on each request | The function runs outside Lambda, or the cache must notice a rotation sooner than its TTL |
| [AWS SDK with a module-scope cache](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/secrets-manager/command/GetSecretValueCommand/) | The SDK is already in the bundle and the function only ever runs on Lambda | A cache you write and test yourself, TTL and failure path included, and a handler tied to one vendor's client | The same handler has to run somewhere without AWS credentials in the environment |
| [sekreto](https://github.com/voxgig/sekreto) | The handler should name a secret without naming the store, and run unchanged against a vault or a `.env` file elsewhere | A dependency in the bundle, an in-tree signer instead of the AWS SDK, and a cache with no TTL until you call `refresh` | You 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](https://docs.aws.amazon.com/lambda/latest/dg/best-practices.html) says to initialize
SDK clients outside the handler for that reason, and a secret is the same shape of thing.

```ts title="handler-sdk.mjs"
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](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html)
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]

```ts title="handler-extension.mjs"
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](https://docs.aws.amazon.com/lambda/latest/dg/with-secrets-manager.html) 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:

```json title="policy.json"
{
  "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](https://docs.aws.amazon.com/secretsmanager/latest/userguide/auth-and-access_iam-policies.html)
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.

```ts title="handler-sekreto.mjs"
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.

```bash
node harness.mjs
```

```text output
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.

```bash
node --test secrets.test.mjs
```

```text output
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`](https://docs.aws.amazon.com/lambda/latest/api/API_GetFunctionConfiguration.html)
gets them back in the response. Only a
[customer managed key](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars-encryption.html)
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](https://12factor.net/config) 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.

## Related how-tos

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Rotate API keys without breaking your clients](/howto/rotate-api-keys-without-breaking-clients)

- [Route outbound API calls through an egress proxy](/howto/route-outbound-calls-through-an-egress-proxy)

## 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.

[^1]: The header is a proof of origin rather than a credential of its own. The
[extension documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html)
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.

[^2]: A layer is an ARN per region with a version number on the end, and the
[table](https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html)
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.

[^3]: The count that proves the cache is not a Secrets Manager metric at all. Its
[monitoring guide](https://docs.aws.amazon.com/secretsmanager/latest/userguide/monitoring-cloudwatch.html)
sends you to Usage, By AWS Resource, in the CloudWatch console. There the
[AWS/Usage namespace](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Usage-Metrics.html)
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.

[^4]: The [environment variables page](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html)
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](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars-encryption.html)
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.