Lambda environment variables are configuration, not a secrets store: they are encrypted at rest, but anyone with lambda:GetFunctionConfiguration can read them in plaintext, they appear in the console, and they get copied around in IaC state and deploy tooling. The working pattern is to keep plain config in environment variables, keep secrets in Parameter Store or Secrets Manager, and put only the secret’s name in the environment — then fetch and cache the value at init so you are not paying an API call per invocation.
What’s happening
Environment variables live in the function configuration, capped at 4 KB total across all keys. Lambda encrypts them at rest with a KMS key, which protects the storage — not the access path. The IAM action that reads function configuration is extremely common in read-only and developer roles, so a database password in an environment variable is effectively readable by half the org, and by every tool that ever calls GetFunction. It also lands in Terraform or CloudFormation state, deploy logs, and local .env reconstructions. That is the real argument against secrets in env vars: distribution, not encryption.
The two proper stores split cleanly on features. SSM Parameter Store, standard tier, is free, holds plain strings and SecureString values encrypted with KMS, and is plenty for API keys, feature flags, and connection strings. Secrets Manager charges per secret per month plus per API call, and buys you managed rotation (native for RDS credentials), cross-account access, and automatic replication. The full decision table is in Secrets Manager vs Parameter Store; the short version for Lambda: rotation or cross-account means Secrets Manager, everything else usually means Parameter Store.
The performance question is where Lambda differs from servers. A server fetches a secret once at boot and holds it for days. A Lambda execution environment boots often, and a naive “fetch the secret in the handler” design turns every invocation into an SSM or Secrets Manager API call — latency, throttling risk (Parameter Store standard throughput is limited), and for Secrets Manager, per-call cost at volume. The fix is caching: fetch in the init phase so the value lives for the environment’s lifetime, or use the AWS Parameters and Secrets Lambda Extension, which runs a local caching proxy on localhost:2773 inside the execution environment with a configurable TTL.
What to check first
- What is currently in the environment variables. An audit takes minutes and regularly finds database passwords that have been there for years.
- Whether anything rotates. If the answer is “we would redeploy,” rotation will never actually happen — that is the strongest Secrets Manager signal.
- Where the fetch happens in code. Handler-path fetches multiply API calls by invocation count; init-path fetches multiply by cold start count.
- The function’s IAM policy. It should allow reading the specific parameters or secrets it needs, by ARN or path prefix — not
ssm:GetParameteron*.
How to verify
Audit what the function exposes today:
aws lambda get-function-configuration --function-name my-function \
--query 'Environment.Variables'
If anything in that output makes you wince, that is the finding. Check what the function is allowed to read:
aws iam list-attached-role-policies --role-name my-function-role
aws iam get-role-policy --role-name my-function-role --policy-name secrets-access
Store a secret properly and read it back:
aws ssm put-parameter --name /prod/api/stripe-key \
--type SecureString --value 'sk_live_example'
aws ssm get-parameter --name /prod/api/stripe-key --with-decryption \
--query 'Parameter.Value'
In the console, the function’s variables sit under Lambda > Functions > (function) > Configuration > Environment variables — visibly, which is rather the point. Service documentation lives at https://docs.aws.amazon.com/systems-manager/ and https://docs.aws.amazon.com/secretsmanager/.
The approach
- Sort every value into config or secret. Log level, table name, downstream URL: environment variable. Anything that would matter in a leak — passwords, API keys, signing keys: a store. When in doubt, treat it as a secret.
- Default to Parameter Store; upgrade to Secrets Manager on a trigger. The triggers: managed rotation (RDS credentials especially), cross-account sharing, or multi-region replication of the secret itself. Absent a trigger, Parameter Store standard tier does the job for free.
- Put the reference in the environment, not the value.
STRIPE_KEY_PARAM=/prod/api/stripe-keyin the env vars; the function resolves it at runtime. Environments differ only in the path prefix. - Fetch once per environment, not once per invocation. Either fetch in init code (outside the handler), or attach the AWS Parameters and Secrets Lambda Extension layer and call
http://localhost:2773/systemsmanager/parameters/get?name=...with theX-Aws-Parameters-Secrets-Token: $AWS_SESSION_TOKENheader. The extension handles TTL-based refresh, which plain init caching does not. - Scope IAM to the path. Grant
ssm:GetParameteronarn:aws:ssm:*:*:parameter/prod/api/*(pluskms:Decrypton the relevant key), so a compromised function cannot read the whole account’s parameters. - Plan for rotation reality. With init-phase caching, a rotated secret is picked up only by new execution environments — old warm ones keep the stale value for hours. Either use the extension’s TTL, or catch auth failures and re-fetch once before failing.
When this is the wrong move
For a hobby project or an internal tool with one developer, a SecureString parameter read at init is the whole story — adding Secrets Manager, rotation Lambdas, and extension layers is ceremony without threat model. Conversely, if your compliance regime requires demonstrated rotation and audit of every credential access, Parameter Store’s free tier is the wrong economy — Secrets Manager’s per-secret cost is the cheapest audit finding you will ever close.
Common mistakes
- Treating KMS encryption of env vars as making them safe — the exposure is IAM read access and tooling sprawl, which encryption at rest does nothing about.
- Fetching secrets inside the handler and discovering Parameter Store throttling the first time traffic spikes.
- Caching at init with no refresh story, then having a rotation “break production” because warm environments held the old password.
- One IAM statement with
Resource: "*"for SSM, making every function a skeleton key.
Keeping it under control
- Run a quarterly audit of
Environment.Variablesacross all functions — a short script overaws lambda list-functionscatches regressions. - Enforce naming conventions (
/env/service/purpose) so IAM path scoping stays clean. - Alarm on Secrets Manager and SSM
GetParametercall volume per function; a jump means someone moved a fetch into the handler path. - Test rotation in staging end to end, including warm-environment behaviour, before trusting it in production.
The pattern is boring and that is its virtue: references in the environment, values in a store, one fetch per environment, IAM scoped to a path. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.