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?

AWSParameter StoreCDKLambda

In this tutorial, I will show you how to:

  1. Set up an unencrypted Parameter in the AWS Parameter Store using the Python version of the AWS Cloud Development Kit.
  2. 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:

Two Lambdas One Stack

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:

bash
./my_first_python_stack/my_first_python_stack_stack.py

You should see the following code:

python
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:

python
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 the StringParameter construct 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 the ssm client 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:

ini
SomeRandomKey=laksjdflaskfjsadf0--a-sdfashdkf--27asjkhfskjhlaksdfh

Then use the python-dotenv package to load it from this file:

python
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:

python
secret.grant_read(fn)

The final CDK file should look something like this:

python
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:

php
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 ssm client.
  • A get_parameter call.

You get the first from boto3, and call the second on the client it creates:

python
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:

python
{
  "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:

python
actual_value = my_parameter["Parameter"]["Value"]

Putting all this together, and continuing the new and improved Lambda function will look like this:

php
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:

bash
cdk deploy

You’ll see an IAM statement similar to the following:

bash
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:

python
import requests

response = requests.post("<Your-Url-Here>", json={"calledBy1": "MyFunction"})
print(response.content)

You should get a response like the following:

python
{
    "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:

bash
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