Using Parameter Store in Lambda is my first answer to a question every function asks sooner or later: where do prices, switches and API keys go when they should change without anyone redeploying? The service costs nothing in the normal case, fits neatly into IAM and takes one line to wire up with the Serverless Framework. Even so, the same pitfalls keep showing up: secrets in plain text in environment variables, a GetParameter call on every request and, as soon as load arrives, a sudden ThrottlingException.

In this article you build a small API for a fictional bike rental, step by step. It reads the hourly price, a pause switch and a payment key from Parameter Store, caches the values sensibly and deploys with a single command. On top of that you get my rule of thumb for when to resolve values at deploy time and when at runtime.

In short

Configuration belongs in Parameter Store, and Lambda should read it at runtime with a cache instead of asking again on every invocation. Values that never change without a deployment can be resolved by the Serverless Framework at deploy time. The biggest pitfall: without a cache you quickly hit the default limit of 40 reads per second for the whole account and Region.

What you will learn in this article

  • Which three ways exist to use Parameter Store in Lambda, and when each one fits
  • How to load and cache values at runtime with the Serverless Framework and Powertools
  • How to decrypt SecureString parameters with KMS without leaking them
  • Which mistakes lead to throttling, plain-text secrets and stale values

What is Parameter Store?

Parameter Store is part of AWS Systems Manager. You store key-value pairs whose names are built like paths, for example /bike-rental/dev/price-per-hour-cents. This hierarchy is more than cosmetics. In IAM, the path controls which function may read which values, and a prefix per stage keeps development and production cleanly apart.

There are three types. String is plain text, StringList is a comma-separated list, and SecureString is a value that AWS stores encrypted with a KMS key. KMS (Key Management Service) is the AWS service for cryptographic keys. Without a key of your own, Parameter Store uses the AWS managed key aws/ssm.

AWS also distinguishes two tiers. Standard parameters can be up to 4 KB in size, you can create up to 10,000 of them per Region, and they cost nothing extra. Advanced parameters allow 8 KB and policies such as an expiration date, but they are charged. For the configuration of a Lambda function, the standard tier is almost always enough.

Three ways to use Parameter Store in Lambda

Before we build anything, it pays to look at the options. They differ mainly in when the value is read and how quickly a change reaches the function.

Parameter Store in Lambda: deploy-time variable, Powertools cache and Lambda extension compared by update delay

When a changed value reaches the function depends on the option you choose.

Resolving at deploy time

The Serverless Framework knows the variable ${ssm:/path/to/parameter}. During deployment, the framework reads the value and writes it into, for example, an environment variable of the function. At runtime there isn’t a single call to Parameter Store. That is fast and simple, but it has a catch: a change only takes effect after the next deployment. In addition, the framework decrypts SecureString parameters automatically. If you write such a key into an environment variable, it shows up in plain text in the Lambda console, and so it does for everyone with the lambda:GetFunctionConfiguration permission.

Loading at runtime with a cache

Here the function reads the values itself through the AWS SDK and keeps them in memory for a while. Because a Lambda execution environment handles many invocations one after another, it only asks Parameter Store every few minutes. The most convenient way to do this is the Parameters utility from Powertools for AWS Lambda (TypeScript). By default it caches for 5 seconds, and you can change that duration globally or per parameter.

The Lambda extension

AWS also offers the “AWS Parameters and Secrets Lambda Extension” as a layer. An extension is a companion process that runs next to your function in the same environment. It opens a local HTTP endpoint on port 2773 and caches parameters for 300 seconds by default. That is handy if you want to get by without an SDK or work with several languages in your team. In TypeScript projects I still prefer Powertools, because types, tests and the cache all live in the same code.

Criterion At deploy time (${ssm:}) Runtime with Powertools Lambda extension
When is it read? Once per deployment On the first invocation, then after the cache expires On the first invocation, then after the cache expires
A change takes effect after The next deployment The cache age (default 5 s, configurable) The cache age (default 300 s)
Calls to Parameter Store None at runtime A few per environment A few per environment
Suitable for secrets? No, ends up in plain text in the configuration Yes Yes
Extra dependency None npm package Layer

My rule of thumb: everything that only changes with a release (log level, resource names, the feature scope of a stage) I resolve at deploy time. Everything operations should be able to adjust in the running system, plus all secrets, I read at runtime.

Setting up Parameter Store in Lambda step by step

Time to build. The example API calculates the price of a rental: GET /quote?hours=3 returns the total price in cents. The hourly price comes from Parameter Store, and so does a switch that lets the rental pause, for example in winter. The payment key sits next to them as a SecureString.

Prerequisites

  • An AWS account and the AWS CLI with valid credentials
  • Node.js 22 or newer
  • Serverless Framework v4 (npm i -g serverless); if you are still on v3, my article on migrating to Serverless Framework v4 helps
  • Basic TypeScript knowledge

Step 1: Create the parameters

Create the parameters with the AWS CLI. The script takes the stage as an argument and builds the prefix from it.

# scripts/create-parameters.sh – creates the example parameters for one stage set -euo pipefail STAGE="${1:-dev}" PREFIX="/bike-rental/${STAGE}" aws ssm put-parameter --name "${PREFIX}/log-level" --type String --value "INFO" --overwrite aws ssm put-parameter --name "${PREFIX}/price-per-hour-cents" --type String --value "450" --overwrite aws ssm put-parameter --name "${PREFIX}/rental-paused" --type String --value "false" --overwrite aws ssm put-parameter --name "${PREFIX}/payment-api-key" --type SecureString --value "test-key-replace-me" --overwrite echo "Parameters created under ${PREFIX}" 
bash scripts/create-parameters.sh dev 

Expected result: the CLI reports a version for each parameter, "Version": 1 on the first run. In the Systems Manager console under “Parameter Store” you now see four entries below /bike-rental/dev.

Step 2: Set up the project

Create a new directory and install the dependencies. Powertools needs the AWS SDK v3, which is already included in the Lambda Node.js runtimes. You still install it locally for types and tests.

mkdir lambda-parameter-store && cd lambda-parameter-store npm init -y npm install @aws-lambda-powertools/parameters @aws-sdk/client-ssm npm install -D typescript tsx @types/node @types/aws-lambda 
{ "name": "lambda-parameter-store", "version": "1.0.0", "private": true, "type": "module", "scripts": { "typecheck": "tsc --noEmit", "test": "tsx --test test/*.test.ts", "deploy": "serverless deploy" }, "dependencies": { "@aws-lambda-powertools/parameters": "^2.0.0", "@aws-sdk/client-ssm": "^3.700.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.145", "@types/node": "^22.10.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } } 
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true }, "include": ["src", "test"] } 

Step 3: Write serverless.yml

Both worlds meet in serverless.yml. The log level is resolved at deploy time, and the function loads everything else at runtime. The IAM permissions are deliberately narrow: the function may only read parameters below its own prefix and may only use KMS through Parameter Store.

# serverless.yml service: bike-rental-config frameworkVersion: '4' provider: name: aws runtime: nodejs24.x architecture: arm64 region: eu-central-1 stage: ${opt:stage, 'dev'} environment: PARAM_PREFIX: /bike-rental/${sls:stage} # Resolved once at deploy time and baked into the function LOG_LEVEL: ${ssm:/bike-rental/${sls:stage}/log-level} # Default cache lifetime for the Powertools Parameters utility (seconds) POWERTOOLS_PARAMETERS_MAX_AGE: '300' iam: role: statements: - Effect: Allow Action: - ssm:GetParameter - ssm:GetParameters Resource: arn:aws:ssm:${aws:region}:${aws:accountId}:parameter/bike-rental/${sls:stage}/* - Effect: Allow Action: kms:Decrypt Resource: '*' Condition: StringEquals: kms:ViaService: ssm.${aws:region}.amazonaws.com functions: quote: handler: src/handler.quote memorySize: 256 timeout: 5 events: - httpApi: path: /quote method: get 

The kms:ViaService condition makes sure the role can only use the key for requests that go through Parameter Store in the same Region. If you have your own KMS key, replace the asterisk with its ARN.

Step 4: Load and validate the configuration

The heart of it is config.ts. The getParametersByName function fetches all three values with as few API calls as possible and caches them. The pause switch gets a shorter cache of 30 seconds, because it should kick in fast when it matters. All other values use the 300 seconds from the POWERTOOLS_PARAMETERS_MAX_AGE environment variable.

// src/config.ts import { getParametersByName } from '@aws-lambda-powertools/parameters/ssm'; import type { SSMGetParametersByNameOptions } from '@aws-lambda-powertools/parameters/ssm/types'; export interface RentalConfig { pricePerHourCents: number; rentalPaused: boolean; paymentApiKey: string; } const prefix = process.env.PARAM_PREFIX ?? '/bike-rental/dev'; // Turns the raw strings from Parameter Store into a checked config object export function parseConfig(raw: Record<string, unknown>, base: string): RentalConfig { const price = Number(raw[`${base}/price-per-hour-cents`]); if (!Number.isInteger(price) || price <= 0) { throw new Error('price-per-hour-cents must be a positive integer'); } const paused = String(raw[`${base}/rental-paused`]).trim().toLowerCase(); if (paused !== 'true' && paused !== 'false') { throw new Error('rental-paused must be "true" or "false"'); } const key = raw[`${base}/payment-api-key`]; if (typeof key !== 'string' || key.length === 0) { throw new Error('payment-api-key is missing'); } return { pricePerHourCents: price, rentalPaused: paused === 'true', paymentApiKey: key }; } export async function loadConfig(): Promise<RentalConfig> { const props: Record<string, SSMGetParametersByNameOptions> = { [`${prefix}/price-per-hour-cents`]: {}, // Switch that should take effect quickly: shorter cache [`${prefix}/rental-paused`]: { maxAge: 30 }, // SecureString: decrypted with KMS [`${prefix}/payment-api-key`]: { decrypt: true }, }; // Without per-parameter maxAge the value comes from POWERTOOLS_PARAMETERS_MAX_AGE const raw = await getParametersByName(props); return parseConfig(raw as Record<string, unknown>, prefix); } 

Why the extra parseConfig function? In Parameter Store, everything is text. If someone accidentally enters “free” instead of “450”, the function should stop with a clear message instead of selling rentals for NaN cents. This part can also be tested without AWS.

Step 5: Pricing logic and handler

The pricing logic is deliberately a pure function without dependencies. Every started hour counts, at least one and at most 72.

// src/pricing.ts import type { RentalConfig } from './config.js'; export const MAX_HOURS = 72; export type Quote = | { ok: true; hours: number; totalCents: number } | { ok: false; reason: string }; // Every started hour counts, at least one hour, at most MAX_HOURS export function quotePrice(hours: number, config: Pick<RentalConfig, 'pricePerHourCents' | 'rentalPaused'>): Quote { if (config.rentalPaused) { return { ok: false, reason: 'Rentals are paused at the moment' }; } if (!Number.isFinite(hours) || hours <= 0) { return { ok: false, reason: 'hours must be greater than 0' }; } const billed = Math.max(1, Math.ceil(hours)); if (billed > MAX_HOURS) { return { ok: false, reason: `at most ${MAX_HOURS} hours` }; } return { ok: true, hours: billed, totalCents: billed * config.pricePerHourCents }; } 
// src/handler.ts import type { APIGatewayProxyHandlerV2 } from 'aws-lambda'; import { loadConfig } from './config.js'; import { quotePrice } from './pricing.js'; export const quote: APIGatewayProxyHandlerV2 = async (event) => { // Cached for up to 300 seconds per execution environment const config = await loadConfig(); const hours = Number(event.queryStringParameters?.hours ?? '1'); const result = quotePrice(hours, config); if (!result.ok) { return { statusCode: 400, body: JSON.stringify({ message: result.reason }) }; } return { statusCode: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hours: result.hours, totalCents: result.totalCents, // Never return the key itself, only whether it is there paymentReady: config.paymentApiKey.length > 0, }), }; }; 

Note that the handler runs loadConfig() on every invocation. That is no mistake: within the cache age, Powertools returns the value from memory without asking Parameter Store. Only after it expires does it fetch fresh values.

🎬 How the cache saves calls to Parameter Store – this animated diagram only runs on my site: watch it on sascha-ms.de

Step 6: Test and deploy

The tests check the pricing logic and the parsing locally, without any AWS access.

// test/pricing.test.ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { quotePrice } from '../src/pricing.js'; import { parseConfig } from '../src/config.js'; const base = '/bike-rental/test'; const config = { pricePerHourCents: 450, rentalPaused: false }; test('every started hour is billed', () => { assert.deepEqual(quotePrice(2.25, config), { ok: true, hours: 3, totalCents: 1350 }); }); test('paused rental returns a reason', () => { const result = quotePrice(1, { ...config, rentalPaused: true }); assert.equal(result.ok, false); }); test('more than 72 hours is rejected', () => { assert.equal(quotePrice(73, config).ok, false); }); test('parseConfig turns strings into types', () => { const parsed = parseConfig( { [`${base}/price-per-hour-cents`]: '450', [`${base}/rental-paused`]: 'FALSE', [`${base}/payment-api-key`]: 'abc', }, base, ); assert.deepEqual(parsed, { pricePerHourCents: 450, rentalPaused: false, paymentApiKey: 'abc' }); }); test('parseConfig rejects an invalid price', () => { assert.throws( () => parseConfig({ [`${base}/price-per-hour-cents`]: 'free' }, base), /positive integer/, ); }); 
npm test npx tsc --noEmit serverless deploy --stage dev # The URL is listed in the output under "endpoint" curl "https://<api-id>.execute-api.eu-central-1.amazonaws.com/quote?hours=2.5" 

Expected result: npm test reports five passing tests. After the deployment the API answers with {"hours":3,"totalCents":1350,"paymentReady":true}.

Step 7: Change a value in the running system

Now comes the actual benefit. Set the pause switch to true and call the API again.

aws ssm put-parameter --name /bike-rental/dev/rental-paused --type String --value true --overwrite curl -i "https://<api-id>.execute-api.eu-central-1.amazonaws.com/quote?hours=1" 

Within 30 seconds at most, the function answers with status 400 and the message that rentals are paused. No deployment was needed for that. If you change the hourly price instead, it can take up to five minutes until all warm environments see the new value. Plan for that if you want to switch prices at a fixed time.

Reading Parameter Store in Lambda through the extension

If you would rather not add an npm package, the extension replaces the Powertools call. You attach it as a layer. AWS itself publishes the current ARN as a public parameter, for example under /aws/service/aws-parameters-and-secrets-lambda-extension/arm64/latest. Your code then queries a local endpoint.

// src/extension.ts – alternative: read parameters via the AWS Parameters and Secrets Lambda Extension const port = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT ?? '2773'; export async function getViaExtension(name: string, decrypt = false): Promise<string> { const url = `http://localhost:${port}/systemsmanager/parameters/get?name=${encodeURIComponent(name)}&withDecryption=${decrypt}`; const response = await fetch(url, { Source:https://dev.to/sascha-ms/parameter-store-in-lambda-loading-config-the-right-way-24la

Related posts