AWS Systems Manager Parameter Store SecureString with KMS: Hierarchical Configuration Management Across Environments

Intermediate

Introduction: Why Parameter Store with KMS Matters

If you’re still storing database credentials in .env files, passing secrets through environment variables baked into container definitions, or — worse — committing them to version control, this article is for you.

AWS Systems Manager Parameter Store provides a centralized, auditable, and secure way to manage configuration data and secrets. When combined with SecureString parameters encrypted by AWS KMS (Key Management Service) and organized in a hierarchical path structure, you get a production-grade configuration management system that scales cleanly across multiple environments — dev, staging, and production — without duplication headaches or security gaps.

This article is written for intermediate-level AWS practitioners — developers and DevOps engineers who already know the basics of AWS but want a concrete, working approach to secrets and configuration management. We’ll cover real CLI commands, Python code, IAM policies, KMS key setup, and the common mistakes that trip people up in production.

Prerequisites

  • An AWS account with permissions to create SSM parameters, KMS keys, and IAM policies
  • AWS CLI v2 installed and configured (aws configure)
  • Python 3.8+ with boto3 installed (pip install boto3)
  • Basic understanding of IAM policies and ARN structures
  • Familiarity with at least one deployment environment (ECS, Lambda, EC2)

Understanding the Hierarchy: Designing Your Parameter Path Structure

Parameter Store supports hierarchical paths using forward slashes, up to 15 levels deep. This is the foundation of clean multi-environment configuration. The key insight is to treat your parameter paths like a filesystem.

Here’s the structure I recommend and use in production:

/{environment}/{service-name}/{parameter-type}/{parameter-name}

For a concrete example, consider a web application called “order-api” with database and API credentials across three environments:

/dev/order-api/database/host
/dev/order-api/database/port
/dev/order-api/database/username
/dev/order-api/database/password        # SecureString
/dev/order-api/database/connection_string # SecureString
/dev/order-api/stripe/api_key           # SecureString
/dev/order-api/config/log_level
/dev/order-api/config/feature_flag_v2

/staging/order-api/database/host
/staging/order-api/database/password    # SecureString

/prod/order-api/database/host
/prod/order-api/database/password       # SecureString

This hierarchy gives you three critical capabilities:

  • Bulk retrieval: Fetch all parameters for a service in one API call using GetParametersByPath
  • Granular IAM control: Restrict access by environment path (e.g., developers can read /dev/* but not /prod/*)
  • Environment parity: Same parameter names across environments, only the prefix changes

Setting Up a Custom KMS Key for Parameter Encryption

Parameter Store SecureString parameters are encrypted by default using the AWS managed key alias/aws/ssm. However, in any serious multi-environment setup, you should create custom KMS keys — ideally one per environment. Here’s why:

  • You can’t control the key policy on the AWS managed key
  • You can’t distinguish access to dev secrets vs. prod secrets at the KMS layer
  • You can’t use the AWS managed key for cross-account access
  • CloudTrail logging for custom keys gives you cleaner audit trails

Create a KMS Key Per Environment

# Create a KMS key for production secrets
aws kms create-key \
  --description "SSM Parameter Store encryption key for prod environment" \
  --tags TagKey=Environment,TagValue=prod TagKey=ManagedBy,TagValue=devops \
  --region us-east-1

# Note the KeyId from the output, then create an alias
aws kms create-alias \
  --alias-name alias/ssm-prod \
  --target-key-id <key-id-from-output> \
  --region us-east-1

# Repeat for dev and staging
aws kms create-key \
  --description "SSM Parameter Store encryption key for dev environment" \
  --tags TagKey=Environment,TagValue=dev TagKey=ManagedBy,TagValue=devops \
  --region us-east-1

aws kms create-alias \
  --alias-name alias/ssm-dev \
  --target-key-id <key-id-from-output> \
  --region us-east-1

Creating and Managing SecureString Parameters

Creating Parameters via AWS CLI

Let’s create a full set of parameters for the dev environment:

# Plain String parameter — no encryption needed for non-sensitive config
aws ssm put-parameter \
  --name "/dev/order-api/config/log_level" \
  --value "DEBUG" \
  --type String \
  --tags "Key=Environment,Value=dev" "Key=Service,Value=order-api"

# String parameter for non-sensitive infra values
aws ssm put-parameter \
  --name "/dev/order-api/database/host" \
  --value "dev-db.cluster-abc123.us-east-1.rds.amazonaws.com" \
  --type String \
  --tags "Key=Environment,Value=dev" "Key=Service,Value=order-api"

aws ssm put-parameter \
  --name "/dev/order-api/database/port" \
  --value "5432" \
  --type String \
  --tags "Key=Environment,Value=dev" "Key=Service,Value=order-api"

# SecureString parameter — encrypted with our custom KMS key
aws ssm put-parameter \
  --name "/dev/order-api/database/password" \
  --value "dev-s3cret-passw0rd!" \
  --type SecureString \
  --key-id alias/ssm-dev \
  --tags "Key=Environment,Value=dev" "Key=Service,Value=order-api"

aws ssm put-parameter \
  --name "/dev/order-api/stripe/api_key" \
  --value "sk_test_abc123xyz" \
  --type SecureString \
  --key-id alias/ssm-dev \
  --tags "Key=Environment,Value=dev" "Key=Service,Value=order-api"

Important: Tags can only be applied during put-parameter when creating a new parameter. If you use --overwrite to update an existing parameter, the --tags flag is ignored. To update tags on existing parameters, use aws ssm add-tags-to-resource.

Retrieving Parameters

# Get a single parameter (decrypted)
aws ssm get-parameter \
  --name "/dev/order-api/database/password" \
  --with-decryption \
  --query "Parameter.Value" \
  --output text

# Get all parameters under a path (recursive, decrypted)
aws ssm get-parameters-by-path \
  --path "/dev/order-api" \
  --recursive \
  --with-decryption

# Get multiple specific parameters at once
aws ssm get-parameters \
  --names "/dev/order-api/database/host" "/dev/order-api/database/password" \
  --with-decryption

Updating Parameters

# Update an existing parameter (--overwrite is required)
aws ssm put-parameter \
  --name "/dev/order-api/database/password" \
  --value "new-s3cret-passw0rd!" \
  --type SecureString \
  --key-id alias/ssm-dev \
  --overwrite

Python Application Code: Loading Configuration from Parameter Store

Here’s a production-ready Python utility class that loads all parameters for a given environment and service, flattening them into a dictionary your application can use:

import boto3
from botocore.exceptions import ClientError


class SSMConfigLoader:
    """
    Loads hierarchical configuration from AWS SSM Parameter Store.
    Supports SecureString decryption via KMS.
    """

    def __init__(self, environment: str, service_name: str, region: str = "us-east-1"):
        self.environment = environment
        self.service_name = service_name
        self.path_prefix = f"/{environment}/{service_name}"
        self.client = boto3.client("ssm", region_name=region)

    def load_all(self, decrypt: bool = True) -> dict:
        """
        Loads all parameters under /{environment}/{service_name}/
        Returns a flat dict: {'database/host': 'value', 'database/password': 'decrypted_value'}
        """
        parameters = {}
        paginator = self.client.get_paginator("get_parameters_by_path")

        try:
            page_iterator = paginator.paginate(
                Path=self.path_prefix,
                Recursive=True,
                WithDecryption=decrypt,
                PaginationConfig={"MaxItems": 100, "PageSize": 10}
            )

            for page in page_iterator:
                for param in page.get("Parameters", []):
                    # Strip the prefix to get a clean key
                    # e.g., /dev/order-api/database/host -> database/host
                    key = param["Name"].removeprefix(self.path_prefix + "/")
                    parameters[key] = param["Value"]

        except ClientError as e:
            error_code = e.response["Error"]["Code"]
            if error_code == "AccessDeniedException":
                raise PermissionError(
                    f"No permission to read parameters under {self.path_prefix}. "
                    f"Check IAM policy and KMS key permissions."
                ) from e
            raise

        return parameters

    def get_single(self, parameter_suffix: str, decrypt: bool = True) -> str:
        """
        Gets a single parameter value.
        parameter_suffix: e.g., 'database/password'
        """
        full_name = f"{self.path_prefix}/{parameter_suffix}"

        try:
            response = self.client.get_parameter(
                Name=full_name,
                WithDecryption=decrypt
            )
            return response["Parameter"]["Value"]

        except self.client.exceptions.ParameterNotFound:
            raise KeyError(f"Parameter not found: {full_name}")


# Usage example
if __name__ == "__main__":
    import os

    env = os.environ.get("APP_ENV", "dev")
    config = SSMConfigLoader(environment=env, service_name="order-api")

    # Load everything at startup
    all_params = config.load_all()
    print(f"Loaded {len(all_params)} parameters for {env}/order-api")

    db_host = all_params.get("database/host")
    db_password = all_params.get("database/password")  # Already decrypted
    log_level = all_params.get("config/log_level", "INFO")

    print(f"DB Host: {db_host}")
    print(f"Log Level: {log_level}")
    # Never print passwords — this is just to show it works
    print(f"DB Password loaded: {'Yes' if db_password else 'No'}")

A critical design decision here: load parameters once at application startup and cache them in memory. Don’t call Parameter Store on every request. The API has throughput limits (40 TPS for GetParameter in standard tier, higher with advanced tier), and hitting it per-request will throttle your application under load.

IAM Policies: Environment-Scoped Access Control

This is where the hierarchical path structure pays off. You can write IAM policies that grant access by environment path and by KMS key.

Developer Role: Read Access to Dev Only

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadDevParameters",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParametersByPath"
            ],
            "Resource": "arn:aws:ssm:us-east-1:123456789012:parameter/dev/*"
        },
        {
            "Sid": "DecryptDevSecrets",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": "arn:aws:kms:us-east-1:123456789012:key/dev-key-id-here"
        }
    ]
}

Production Application Role: Read Access to Prod, Specific Service Only

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadProdOrderApiParameters",
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParametersByPath"
            ],
            "Resource": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/order-api/*"
        },
        {
            "Sid": "DecryptProdSecrets",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": "arn:aws:kms:us-east-1:123456789012:key/prod-key-id-here"
        }
    ]
}

Key point: Both the SSM permission and the KMS permission are required to read SecureString parameters. If you grant SSM access but forget the KMS Decrypt permission, you’ll get an AccessDeniedException — and the error message won’t always make it clear that KMS is the problem.

Quick Reference: Required Permissions




Action SSM Permission KMS Permission
Read String parameter ssm:GetParameter None
Read SecureString parameter ssm:GetParameter kms:Decrypt
Write String parameter

Leave a Comment

Your email address will not be published. Required fields are marked *