Store Unencrypted Secrets in Parameter Store With CDK
How do you store your secrets cheaply in AWS Parameter Store and use them in a Lambda function?

In this tutorial, I will show you how to:
- Set up an unencrypted Parameter in the AWS Parameter Store using the Python version of the AWS Cloud Development Kit.
- Pull the parameter from the store and use it in a Python Lambda function.
This is not the recommended way to store secrets in AWS. For that, you want Secrets Manager or encrypted strings in Parameter Store. However, in CDK, you cannot directly set up a SecureString parameter (the encrypted type). This leaves unencrypted parameters or a workaround.
For smaller projects, unencrypted is safe-ish. It keeps your code and secrets separated. A user requires a separate AWS role to see the secrets. Opening the door to fine-grained access control.
Step 0 — Prerequisites
This article assumes you have:
- An AWS Account.
- The Python CDK installed.
- A CDK stack to add to.
The article is part of a series that explains how to set up Python Lambda functions in CDK. This is the previous article:
The code associated with the series is on my GitHub. This article follows on from this repo and branch:
GitHub - Connor56/cdk-python-stack at part-2/two-lambdas-one-stack
If you’d like to follow along, clone the repository and checkout the branch part-2/two-lambdas-one-stack. This is the starting point of this article. You can see it finished in part-3/managing-secrets-with-parameter-store.
Step 1 — Add a Secret Key to the CDK Stack With Parameter Store
Open the CDK stack held under:
./my_first_python_stack/my_first_python_stack_stack.py
You should see the following code:
from aws_cdk import (
This set-up creates two Lambda functions, one of which can call the other.
Add a Secret Key
You can add a secret key to this stack with:
from aws_cdk import aws_ssm as ssm
# ...(skip rest of stack)...
secret = ssm.StringParameter(
self,
id="SomeRandomKey",
parameter_name="SomeRandomKey",
description="A random key to test",
string_value=os.getenv("SomeRandomKey"),
)
aws_ssm is the CDK module that builds infrastructure in the AWS Systems Manager. One component of the Systems Manager is the Parameter Store, which lets you centralise your configuration data management. Here’s an overview of what’s happening:
ssm.StringParameter— This is a type of parameter in the Parameter Store. It’s a plain, unencrypted string.self— This is for theStringParameterconstruct to know it’s place in the stack hierarchy.id— The name of the parameter in the CDK stack.parameter_name— The name used to call the parameter from thessmclient in the Lambda function.description— Exactly what it sounds like.string_value— The value stored against the parameter in the Parameter Store.
I’m importing a variable from the local environment for my string_value. This is much safer than hardcoding and storing the variable in the git source control, because of cleaner separation of concerns. Someone can have permission to edit the Lambda without getting access to the environment variables.
To get my variable into the environment, I store it in a .env file at the root of the package:
SomeRandomKey=laksjdflaskfjsadf0--a-sdfashdkf--27asjkhfskjhlaksdfh
Then use the python-dotenv package to load it from this file:
import os
from dotenv import load_dotenv
load_dotenv("../.env")
This makes it available to os.getenv. Finally, I grant my first Lambda function the ability to use the variable with:
secret.grant_read(fn)
The final CDK file should look something like this:
from aws_cdk import (
Stack,
aws_lambda as _lambda,
aws_ssm as ssm,
aws_iam as iam,
CfnOutput,
)
from constructs import Construct
import os
from dotenv import load_dotenv
load_dotenv("../.env")
class MyFirstPythonStackStack(Stack):
"""
Two Lambda functions and dedicated URLS. One function with
permission to invoke the other.
"""
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
# Create a function
fn = _lambda.Function(
self,
"MyFunction",
runtime=_lambda.Runtime.PYTHON_3_11,
code=_lambda.Code.from_asset("lambda"),
handler="lambda_function.lambda_handler",
)
fn_url = fn.add_function_url(
auth_type=_lambda.FunctionUrlAuthType.NONE,
cors=_lambda.FunctionUrlCorsOptions(
allowed_origins=["*"],
allowed_methods=[_lambda.HttpMethod.ALL],
allowed_headers=["*"],
),
)
# Create a parameter store secret
secret = ssm.StringParameter(
self,
id="SomeRandomKey",
parameter_name="SomeRandomKey",
description="A random key to test",
string_value=os.getenv("SomeRandomKey"),
)
# Grent read access to the first function
secret.grant_read(fn)
# Create a function
fn2 = _lambda.Function(
self,
"MyFunction2",
runtime=_lambda.Runtime.PYTHON_3_11,
code=_lambda.Code.from_asset("lambda"),
handler="lambda_function_2.lambda_handler",
)
fn_url2 = fn2.add_function_url(
auth_type=_lambda.FunctionUrlAuthType.NONE,
cors=_lambda.FunctionUrlCorsOptions(
allowed_origins=["*"],
allowed_methods=[_lambda.HttpMethod.ALL],
allowed_headers=["*"],
),
)
# Grant fn permission to invoke fn2
fn.add_to_role_policy(
iam.PolicyStatement(
actions=["lambda:InvokeFunction"],
resources=[fn2.function_arn],
)
)
# This can also be done with
# fn.grant_invoke(fn2)
# Expose the second function's ARN to the first function
fn.add_environment("PEER_FN_ARN", fn2.function_arn)
CfnOutput(
self,
"MyFunctionUrl",
value=fn_url.url,
description=f"URL for the MyFunction Lambda function",
)
CfnOutput(
self,
"MyFunction2Url",
value=fn_url2.url,
description=f"URL for the MyFunction2 Lambda function",
)
Step 2 — Access the Parameter in a Lambda Function
If you’re working from my GitHub repository, open the Lambda function stored at lambda/lambda_function.py. It should look something like this:
import json
from typing import Dict, Any
import boto3
import os
# Set up the client to communicate with AWS and the other Lambda function
client = boto3.client("lambda")
def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
"""
AWS Lambda handler
:param event: dict, the event data sent by the requester
:param context: LambdaContext, runtime information
:return: dict,
"""
body = json.loads(event["body"])
# Standard Response
if "calledBy" in body:
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"message": f"Hello, {body['calledBy']}!"}),
}
# Call secondary Lambda function to get response
response = client.invoke(
FunctionName=os.environ["PEER_FN_ARN"],
InvocationType="RequestResponse",
Payload=json.dumps(
{
"calledBy": "MyFunction",
}
),
)
# Parse secondary Lambda function's response
result = json.loads(response["Payload"].read())
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(result),
}
To access the parameter, we need two things:
- An
ssmclient. - A
get_parametercall.
You get the first from boto3, and call the second on the client it creates:
ssm = boto3.client("ssm")
# ...(skip rest of Lambda)...
some_random_key = ssm.get_parameter(Name="SomeRandomKey")
This will return a dictionary with the following structure:
{
"Parameter": {
"Name": "SomeRandomKey",
"Type": "String",
"Value": "<Your-Value-Here>",
"Version": 1,
"LastModifiedDate": datetime.datetime(
2025, 5, 3, 19, 55, 27, 269000, tzinfo=tzlocal()
),
"ARN": "arn:aws:ssm:eu-west-2:85172723842:parameter/SomeRandomKey",
"DataType": "text",
},
"ResponseMetadata": {
"RequestId": "4280dc3c-e280-44b9-9b8f-926babe353f0",
"HTTPStatusCode": 200,
"HTTPHeaders": {
"server": "Server",
"date": "Sun, 04 May 2025 09:18:09 GMT",
"content-type": "application/x-amz-json-1.1",
"content-length": "250",
"connection": "keep-alive",
"x-amzn-requestid": "4280dc3c-e280-44b9-9b8f-926babe353f0",
},
"RetryAttempts": 0,
},
}
We only care about the Value stored in the Parameter, so to get that value we use:
actual_value = my_parameter["Parameter"]["Value"]
Putting all this together, and continuing the new and improved Lambda function will look like this:
import json
from typing import Dict, Any
import boto3
import os
# Set up the client to communicate with AWS and the other Lambda function
client = boto3.client("lambda")
ssm = boto3.client("ssm")
def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
"""
AWS Lambda handler
:param event: dict, the event data sent by the requester
:param context: LambdaContext, runtime information
:return: dict,
"""
body = json.loads(event["body"])
# Standard Response
if "calledBy" in body:
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"message": f"Hello, {body['calledBy']}!"}),
}
# Call secondary Lambda function to get response
response = client.invoke(
FunctionName=os.environ["PEER_FN_ARN"],
InvocationType="RequestResponse",
Payload=json.dumps(
{
"calledBy": "MyFunction",
}
),
)
# Parse secondary Lambda function's response
result = json.loads(response["Payload"].read())
some_random_key = ssm.get_parameter(Name="SomeRandomKey")
result["some_random_key"] = some_random_key["Parameter"]["Value"]
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(result),
}
Step 3 — Deploy and Test
Now you can check this worked by running:
cdk deploy
You’ll see an IAM statement similar to the following:
IAM Statement Changes
┌───┬───────────────────────────────────────────────────────────────────────────────────────────────┬────────┬────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────┬───────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼───────────────────────────────────────────────────────────────────────────────────────────────┼────────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────┼───────────┤
│ + │ ${MyFunction2.Arn} │ Allow │ lambda:InvokeFunction │ AWS:${MyFunction/ServiceRole} │ │
│ + │ ${MyFunction2.Arn} │ Allow │ lambda:InvokeFunctionUrl │ * │ │
├───┼───────────────────────────────────────────────────────────────────────────────────────────────┼────────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────┼───────────┤
│ + │ ${MyFunction2/ServiceRole.Arn} │ Allow │ sts:AssumeRole │ Service:lambda.amazonaws.com │ │
├───┼───────────────────────────────────────────────────────────────────────────────────────────────┼────────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────┼───────────┤
│ + │ ${MyFunction.Arn} │ Allow │ lambda:InvokeFunctionUrl │ * │ │
├───┼───────────────────────────────────────────────────────────────────────────────────────────────┼────────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────┼───────────┤
│ + │ ${MyFunction/ServiceRole.Arn} │ Allow │ sts:AssumeRole │ Service:lambda.amazonaws.com │ │
├───┼───────────────────────────────────────────────────────────────────────────────────────────────┼────────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────┼───────────┤
│ + │ arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${SomeRandomKeyF0B263F7} │ Allow │ ssm:DescribeParameters │ AWS:${MyFunction/ServiceRole} │ │
│ │ │ │ ssm:GetParameter │ │ │
│ │ │ │ ssm:GetParameterHistory │ │ │
│ │ │ │ ssm:GetParameters │ │ │
└───┴───────────────────────────────────────────────────────────────────────────────────────────────┴────────┴────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬────────────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│ │ Resource │ Managed Policy ARN │
├───┼────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${MyFunction2/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
├───┼────────────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${MyFunction/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴────────────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Accept these changes with a y and let the stack deploy. Once its done you’ll see two output URLs. Copy the one for the first Lambda function and create a new Python script with the following in it:
import requests
response = requests.post("<Your-Url-Here>", json={"calledBy1": "MyFunction"})
print(response.content)
You should get a response like the following:
{
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": '{"message": "Hello, this is Lambda 2!"}',
"some_random_key": "laksjdflaskfjsadf0--a-sdfashdkf--27asjkhfskjhlaksdfh",
}
Voila! You have access to a key and value from the parameter store.
Step 4 — Tear Down the Stack
This stack is all serverless, so it's relatively safe to leave it deployed. However, to be certain you don’t get charged, you can remove it from your account with:
cdk destroy
Final Words
That’s it! You now know how to manage parameter store secrets in AWS, and you’re one step closer to building a complete backend in Python! If you want to see how to manage the packages in your Lambda environment, stay tuned, that’s coming next.
The finished code is on my GitHub here:
GitHub - Connor56/cdk-python-stack at part-3/managing-secrets-with-parameter-store