How to Pick Between Env Vars, Parameters, and Secrets

June 28, 2027 · 14 min read

Developer · DVA-C02 · part of The Exam Room

The situation

A payments team runs a handful of Lambdas and a platform engineer has asked the question every platform engineer eventually asks: where should we put configuration, and can we get the practice consistent across the team so new code doesn’t invent its own answer?

The Lambdas need three kinds of values.

  1. Table names, queue URLs, bucket names. Deploy-time values. They change when the stack is redeployed; between deploys, they’re stable. Today they’re in CloudFormation Outputs and passed into the Lambda as environment variables.
  2. Feature flags and tuning knobs. Operational values. A threshold the team adjusts weekly; a kill switch to disable a feature without deploying; a retry count. Today they’re also environment variables, which means a tuning change requires a Lambda version update.
  3. Third-party API credentials. Secret values. A Stripe API key; a DataDog API key; an OAuth client secret. Today they’re in environment variables, which means they appear in plaintext in the CloudFormation template, in the Lambda function configuration, in anyone’s CloudTrail history who looked at the function, and occasionally in logs.

Everything is in environment variables today. The team has noticed the third category is wrong (they appear in logs); they haven’t yet noticed the second category is annoying (every tuning change is a deploy). The question is what to do about each.

What actually matters

The core trade across these three services is refresh cadence vs retrieval cost vs secrecy guarantees.

The first thing to ask is: how often does the value change? Environment variables change when the function is deployed. Parameter Store and Secrets Manager change whenever someone calls the update API. If the value should change without a deploy, environment variables are the wrong answer.

The second thing to ask is: who needs to know the value, and who is allowed to? Environment variables are visible to anyone with lambda:GetFunctionConfiguration on the function, a standard read permission for developers. Parameter Store and Secrets Manager gate access with their own IAM, can encrypt with a separate KMS key, and emit CloudTrail events for every read. If the value is sensitive, environment variables leak it to anyone who can describe the function.

The third is cost at runtime. An environment variable is read from the process when the function starts; no API call, no cost. A Parameter Store or Secrets Manager value costs an API call to fetch, pennies per thousand, but real at high call volumes.

The fourth is rotation story. Secrets Manager has built-in rotation support for databases, the full rotation Lambda pattern, and staging labels. Parameter Store has no rotation; if a value rotates there, something else is triggering the update. Environment variables don’t rotate at all without a deploy.

The fifth is hierarchy and shape. Parameter Store supports a /payments/prod/api/retry-count path structure and wildcard reads (GetParametersByPath). Secrets Manager supports JSON blobs with multiple fields per secret. Environment variables are flat key-value.

And finally, a softer one: cold start and runtime caching. A Lambda that calls GetParameter on every invocation adds tens of milliseconds and a cost per call. A well-designed handler caches the value across invocations (because the Lambda execution environment lives longer than one invocation) and either refreshes on a TTL or uses the AWS Parameters and Secrets Lambda Extension, which caches values in a sidecar process.

Side by side

Attribute Env vars Parameter Store (SSM) Secrets Manager
Changes without deploy
Encrypted at rest ✓ (KMS, by default for Lambda) ✓ (SecureString, KMS) ✓ (always, KMS)
Built-in rotation
Retrieval cost Free Free (Standard), $0.05/10k (Advanced) Per-GetSecretValue call
Per-value IAM Function-wide Per parameter ARN Per secret ARN
CloudTrail on reads ✗ (env var read isn’t tracked)
Suitable shape Small, deploy-time Config of any shape, hierarchical Credentials and structured secrets
Versioning Function version Parameter version Secret version + staging labels
Max value size 4 KB total env var block 4 KB (Standard), 8 KB (Advanced) 64 KB
Price for being there $0 Free (Standard), $0.05 per advanced param/month $0.40 per secret per month + API calls

Reading the table by value shape rather than by service:

  • Table names, queue URLs, bucket names, deploy-time, change on redeploy, not secret. Environment variables fit perfectly.
  • Feature flags and tuning knobs, change independently of deploys, not secret. Parameter Store is the direct match: cheap, IAM-scopable per parameter path, no deploy required to change.
  • Third-party API credentials, secret, rotate periodically, audit-relevant. Secrets Manager: built for this shape, encryption mandatory, rotation supported, CloudTrail per-read.

Matching configuration shape to service

Deploy-time identifiers, environment variables CloudFormation Environment: TABLE: !Ref OrdersTable QUEUE: !Ref Queue set on deploy Lambda function process.env.TABLE available at cold start zero runtime cost, no refresh without redeploy Not secret stable between deploys readable by any role with lambda:GetFunctionConfiguration Tuning knobs / flags. Parameter Store /payments/prod retry-count: 3 flag-new-checkout: on api-timeout-ms: 1500 String / StringList Parameters + Secrets Extension 60 s cache Lambda handler HTTP GET http://localhost:2773/systemsmanager/parameters/get cached value returned; no SSM API call in the hot path new value in Parameter Store picked up within cache TTL Third-party credentials. Secrets Manager stripe/api-key {"api_key":"sk_live_..."} KMS-encrypted AWSCURRENT rotation: 30 days same extension secretsmanager path 5 min cache Lambda handler HTTP GET http://localhost:2773/secretsmanager/get CloudTrail records secret reads; IAM scoped per secret ARN on rotation, cache refreshes and handler sees new value
Three configuration shapes, three storage services. The Lambda Extension makes Parameter Store and Secrets Manager behave like a short-TTL cache rather than an API call on the hot path.

The picks in depth

Deploy-time identifiers → environment variables. Table names, queue URLs, bucket names, the function’s own Region. The CloudFormation template sets them on the function, and the function reads them from process.env (Node), os.environ (Python), or the language equivalent. They’re encrypted at rest by Lambda using either the AWS-managed aws/lambda key or a customer-managed KMS key (configurable per function).

What they are not, even with that encryption: secret. Anyone with lambda:GetFunctionConfiguration on the function sees them in the console, in the CLI output, in the CloudTrail event for that call. The encryption at rest is belt-and-braces, not a disclosure control. Putting credentials here is the shape mismatch the team has noticed.

Tuning knobs and flags → Parameter Store. Paths named /payments/prod/retry-count, /payments/prod/flag-new-checkout, /payments/prod/api-timeout-ms. The Lambda’s execution role has ssm:GetParameter / ssm:GetParametersByPath scoped to arn:aws:ssm:eu-west-1:<account>:parameter/payments/prod/*. Updates are one API call: aws ssm put-parameter --name /payments/prod/retry-count --value 5 --overwrite. The Lambda picks up the new value on the next cache refresh; no deploy.

Parameter Store has two tiers. Standard is free and supports parameters up to 4 KB and throughput up to the default 40 transactions per second per account. Advanced costs $0.05 per parameter per month and raises the limit to 8 KB, 10,000 parameters (from 10,000), and supports parameter policies like expiration. For most tuning-knob cases, Standard is correct.

Retrieval cost and caching: every GetParameter is an API call. The AWS Parameters and Secrets Lambda Extension, a layer available from AWS, runs a small HTTP server inside the Lambda execution environment at http://localhost:2773 that caches parameters (default TTL: 5 minutes for secrets, configurable for parameters). The handler makes a localhost HTTP request instead of an SDK call; the extension handles the SDK call, the caching, and the TTL. Cold-start cost: one load of the layer. Warm cost: tens of microseconds.

Third-party credentials → Secrets Manager. A secret named payments/stripe/api-key with a JSON payload {"api_key":"sk_live_..."}. The Lambda’s role has secretsmanager:GetSecretValue scoped to the secret ARN; CloudTrail records every read with the caller’s principal; rotation is configured against a rotation Lambda if the key rotates (for API keys, rotation is usually a human-coordinated event with the vendor, not an automated one).

Retrieval goes through the same extension, on a different path: http://localhost:2773/secretsmanager/get?secretId=payments/stripe/api-key. Default cache TTL is 5 minutes; when the secret rotates, the extension refreshes on the next miss and the handler sees the new value within five minutes at most.

Cost: Secrets Manager charges $0.40 per secret per month and $0.05 per 10,000 API calls. For a handful of third-party credentials it’s rounding; for hundreds of per-tenant secrets it’s real, and the Parameter Store SecureString option becomes more attractive (which is the other way to store a secret, in Parameter Store, KMS-encrypted, without Secrets Manager’s rotation story). SecureString is a parameter type that ties a parameter value to a KMS key for encryption; accessing it requires kms:Decrypt as well as ssm:GetParameter.

When Parameter Store’s SecureString overlaps with Secrets Manager

Both store encrypted values. Both cost around the same for low volumes. Both work through the extension. Three differences settle the choice:

  1. Rotation. Secrets Manager ships rotation; Parameter Store doesn’t.
  2. Size. Secrets Manager supports 64 KB values; Parameter Store Standard is 4 KB and Advanced is 8 KB.
  3. Replication. Secrets Manager replicates to other Regions with a single setting; Parameter Store doesn’t natively replicate.

For a rotating database credential with multiple fields, Secrets Manager is the clean answer. For a static token stored encrypted, Parameter Store SecureString is cheaper and just as secure.

A worked handler with the extension

Node Lambda pulling a table name (env var), a retry count (Parameter Store), and a Stripe key (Secrets Manager):

// Env var -- read once on cold start
const TABLE = process.env.TABLE;

// Parameter Store and Secrets Manager via extension
async function getParam(name) {
  const res = await fetch(
    `http://localhost:2773/systemsmanager/parameters/get?name=${encodeURIComponent(name)}`,
    { headers: { 'X-Aws-Parameters-Secrets-Token': process.env.AWS_SESSION_TOKEN }}
  );
  return (await res.json()).Parameter.Value;
}

async function getSecret(id) {
  const res = await fetch(
    `http://localhost:2773/secretsmanager/get?secretId=${encodeURIComponent(id)}`,
    { headers: { 'X-Aws-Parameters-Secrets-Token': process.env.AWS_SESSION_TOKEN }}
  );
  return JSON.parse((await res.json()).SecretString);
}

export async function handler(event) {
  const retry = parseInt(await getParam('/payments/prod/retry-count'));
  const { api_key } = await getSecret('payments/stripe/api-key');
  // ... use TABLE, retry, api_key ...
}

The first invocation after cold start fetches both; subsequent invocations within the cache TTL get them from the extension’s in-memory cache. When the retry count is updated in Parameter Store, the handler picks it up within the TTL (default 5 minutes; tune per parameter via the extension configuration).

What’s worth remembering

  1. Match refresh cadence to storage. Deploy-time → env var. Runtime-tunable → Parameter Store. Secret → Secrets Manager.
  2. Env vars aren’t a secret store. Encryption at rest doesn’t hide them from anyone with GetFunctionConfiguration.
  3. Parameter Store has two tiers. Standard is free and fits most tuning needs; Advanced adds size, quantity, and parameter-policy features for $0.05 per parameter per month.
  4. Secrets Manager is Parameter-Store-plus-rotation. Pay the $0.40 per secret per month for the rotation story; use Parameter Store SecureString when rotation isn’t needed.
  5. The Parameters and Secrets Lambda Extension is the caching story. Localhost HTTP GET, in-process cache, TTL configurable. The hot-path cost drops from an SDK call to a microsecond.
  6. IAM per-resource is the key control. Each parameter ARN or secret ARN is a separable grant; functions get only what they need.
  7. CloudTrail tracks every read. For Parameter Store and Secrets Manager, every GetParameter and GetSecretValue is a CloudTrail event with principal, timestamp, and resource. Env-var reads are invisible to CloudTrail, the Lambda just reads its own memory.
  8. Hierarchies help at scale. /payments/prod/*, /payments/dev/* organises parameters across environments; wildcard IAM conditions scope access per team.
  9. Size limits bite. 4 KB total env vars, 4/8 KB per parameter (Standard/Advanced), 64 KB per secret. Don’t try to serialise JSON blobs of config into env vars.
  10. Changing a value shouldn’t need a deploy. If it does, the shape is wrong. Move it to Parameter Store; the ops team can adjust without engineering.

Env vars for deploy-time identifiers, Parameter Store for tunable operational values, Secrets Manager for rotating credentials. Three shapes, three services, one extension to keep the retrieval cheap. The work isn’t picking a favourite, it’s matching each value’s lifetime and secrecy to the service that fits it.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.