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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| AWS Parameters and Secrets Lambda Extension | 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 | 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 | 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 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.
Related how-tos
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
-
The header is a proof of origin rather than a credential of its own. The extension documentation says the
X-Aws-Parameters-Secrets-Tokenvalue is the function’s session token, and that for most functions it is provided inAWS_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 -
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-1was at version 116 andus-east-2at 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 -
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
CallCountwith 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 -
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:Decrypton 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