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.
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-24laRelated posts
Your Agent's Tool List Is Not a Permission Model
Vom Voxel-Stadtplan zum begehbaren Protein: Was Warschau in Voxeln über immersives Lernen lehrt

