AWS Parameter Store vs Secrets Manager: When to Use Which

AWS Parameter Store vs Secrets Manager

Every AWS application needs configuration and credentials at runtime — database passwords, API keys, feature flags, environment URLs, encryption keys. AWS offers two managed services for this: Systems Manager Parameter Store (commonly called SSM Parameter Store) and AWS Secrets Manager. They overlap significantly, both encrypting values with KMS, both accessible via IAM, both integrated with Lambda, ECS, and EKS. Yet they are optimized for fundamentally different use cases.

Choosing the wrong service means either paying $0.40/secret/month for parameters that didn't need it, or missing automatic rotation for credentials that desperately need it. This guide cuts through the confusion with a complete feature comparison, deep-dives into both services, and a clear decision framework for every scenario you'll encounter in production.

1. Full Feature Comparison Table

Before going deep on either service, here is the side-by-side view of every differentiating feature. Bookmark this table — it answers 80% of "which should I use?" questions instantly.

Feature SSM Parameter Store Secrets Manager
Primary purposeConfiguration + non-rotating secretsCredentials requiring automatic rotation
Cost per secretFree (Standard tier) / $0.05/10k API calls (Advanced)$0.40/secret/month + $0.05/10k API calls
Free tier10,000 standard parameters free forever30-day free trial only
Value size limit4 KB (Standard) / 8 KB (Advanced)65,536 bytes (~64 KB)
Parameter/secret typesString, StringList, SecureStringKey-value JSON or plaintext string
Automatic rotationNo (manual only)Yes — native RDS/Aurora/Redshift rotators + custom Lambda
Rotation scheduleN/ADays interval or cron expression
VersioningYes — up to 100 versions per parameter, labeledYes — AWSCURRENT / AWSPENDING / AWSPREVIOUS staging labels
Hierarchies / pathsYes — /app/prod/db-password style paths, GetParametersByPathNo native hierarchy; naming convention only
Cross-account accessNo (parameter policies don't support resource-based policies)Yes — resource-based policy on secret
Multi-region replicationNoYes — replica secrets in up to 100 regions
KMS encryptionOptional (SecureString type); Standard params can use SSM-managed keyAlways encrypted; CMK or aws/secretsmanager key
VPC endpointYes (ssm endpoint)Yes (secretsmanager endpoint)
CloudTrail auditingYesYes
Parameter policiesYes — expiration, no-change notification, expiration notification (Advanced only)N/A (rotation replaces this)
Throughput40 TPS standard / 100 TPS advanced (per region)10,000 TPS per region
ECS native injectionYes (valueFrom with SSM ARN)Yes (valueFrom with Secrets Manager ARN)
Lambda extension layerAWS Parameters and Secrets Lambda ExtensionAWS Parameters and Secrets Lambda Extension
Key insight: Parameter Store's Standard tier is entirely free for the first 10,000 parameters and the first 10,000 API calls per month. If you're storing non-rotating config (feature flags, URLs, non-secret app config), Parameter Store is the right tool and the right price.

2. Parameter Store Deep-Dive

SSM Parameter Store is a component of AWS Systems Manager. Despite the "Systems Manager" branding suggesting infrastructure operations, Parameter Store is widely used as a general-purpose config and lightweight secrets store for application teams.

Standard vs Advanced Tiers

Every parameter you create is either Standard or Advanced. The default is Standard and it covers the vast majority of use cases:

AttributeStandardAdvanced
Max parameter count per account/region10,000100,000
Max value size4 KB8 KB
Parameter policies (expiration, notification)NoYes
CostFree$0.05 per 10,000 API interactions

Upgrade a parameter to Advanced when you need to store values larger than 4 KB (e.g., a full JSON configuration object, a multi-line certificate chain) or when you need expiration policies to force credential rotation reminders. Note that converting from Advanced back to Standard is not supported, and if you have Advanced parameters but your account limit of 100,000 is reached, writes will fail.

Parameter Types: String, StringList, SecureString

Parameter Store has three data types:

  • String — plain unencrypted text. For non-sensitive config: environment names, feature flags, service endpoints, version strings.
  • StringList — a comma-delimited list stored as a single parameter. Useful for lists of allowed IP CIDRs, tag values, or AMI IDs.
  • SecureString — value encrypted at rest using a KMS key. Use for anything sensitive: passwords, API keys, tokens. The SSM service transparently decrypts on retrieval if the caller has the appropriate KMS permission.
# Create a plain String parameter
aws ssm put-parameter \
  --name "/myapp/prod/db-host" \
  --value "mydb.cluster-xyz.us-east-1.rds.amazonaws.com" \
  --type String \
  --tags "Key=Environment,Value=production" "Key=Service,Value=myapp"

# Create a SecureString — uses default aws/ssm key
aws ssm put-parameter \
  --name "/myapp/prod/db-password" \
  --value "s3cr3tP@ssword!" \
  --type SecureString \
  --tags "Key=Environment,Value=production"

# Create a SecureString with a CMK
aws ssm put-parameter \
  --name "/myapp/prod/stripe-api-key" \
  --value "sk_live_abc123..." \
  --type SecureString \
  --key-id "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123" \
  --tags "Key=Environment,Value=production"

# Retrieve (with decryption)
aws ssm get-parameter \
  --name "/myapp/prod/db-password" \
  --with-decryption \
  --query "Parameter.Value" \
  --output text

Parameter Hierarchies

The most underused Parameter Store feature is its hierarchical naming. Parameter names are just strings, but the SSM console and GetParametersByPath API treat forward slashes as path separators. This lets you fetch entire environment configurations in a single API call:

# Recommended naming convention
/myapp/prod/db-host
/myapp/prod/db-port
/myapp/prod/db-name
/myapp/prod/db-password     # SecureString
/myapp/prod/stripe-api-key  # SecureString
/myapp/staging/db-host
/myapp/staging/db-password

# Fetch ALL production parameters for myapp in one call
aws ssm get-parameters-by-path \
  --path "/myapp/prod" \
  --recursive \
  --with-decryption \
  --query "Parameters[*].{Name:Name,Value:Value}" \
  --output table

This pattern is perfect for Lambda function initialization: one API call populates a local dictionary with all parameters your function needs, and you cache them for the function lifetime.

Versioning and Labels

Every put-parameter call with --overwrite creates a new version. Versions are numbered sequentially (1, 2, 3…) and you can retrieve a specific version or attach a custom label:

# Overwrite creates version 2
aws ssm put-parameter \
  --name "/myapp/prod/db-password" \
  --value "n3wP@ssword!" \
  --type SecureString \
  --overwrite

# Label version 1 as "baseline" for rollback reference
aws ssm label-parameter-version \
  --name "/myapp/prod/db-password" \
  --parameter-version 1 \
  --labels "baseline"

# Retrieve a specific version
aws ssm get-parameter \
  --name "/myapp/prod/db-password:1" \
  --with-decryption

# Retrieve by label
aws ssm get-parameter \
  --name "/myapp/prod/db-password:baseline" \
  --with-decryption

Parameter Store in Terraform

# Store a SecureString parameter
resource "aws_ssm_parameter" "db_password" {
  name        = "/myapp/prod/db-password"
  description = "Production database password for myapp"
  type        = "SecureString"
  value       = var.db_password
  key_id      = aws_kms_key.app_key.arn

  tags = {
    Environment = "production"
    Service     = "myapp"
    ManagedBy   = "terraform"
  }
}

# Read a parameter (for use in other resources)
data "aws_ssm_parameter" "db_host" {
  name            = "/myapp/prod/db-host"
  with_decryption = false
}

# Reference in a Lambda environment variable
resource "aws_lambda_function" "api" {
  # ...
  environment {
    variables = {
      DB_HOST = data.aws_ssm_parameter.db_host.value
    }
  }
}
Terraform state warning: When Terraform reads a SecureString parameter via data.aws_ssm_parameter with with_decryption = true, the plaintext value is stored in your Terraform state file. Encrypt your state backend (S3 with SSE-KMS) and restrict access to it carefully.

3. Secrets Manager Deep-Dive

Secrets Manager was purpose-built to solve the credential rotation problem. Its core differentiator is the ability to automatically rotate secrets on a schedule using a Lambda function, with no application downtime during rotation thanks to its versioning model.

Secret Versioning and Staging Labels

Secrets Manager uses a staging label system rather than integer versions. At any point, a secret has three labeled versions:

  • AWSCURRENT — the active, live version your app should use
  • AWSPENDING — a newly created version during rotation; exists between the "create" and "finish" rotation steps
  • AWSPREVIOUS — the prior AWSCURRENT, kept for a grace period to allow in-flight connections to drain

Automatic Rotation

Secrets Manager includes managed rotation functions for AWS-native database services. For RDS MySQL/PostgreSQL, Aurora, Redshift, and DocumentDB, you can enable rotation with a single console click or CLI call — AWS creates and manages the Lambda function for you:

# Enable automatic rotation for an RDS secret using the managed rotator
aws secretsmanager rotate-secret \
  --secret-id "prod/myapp/rds-credentials" \
  --rotation-lambda-arn "arn:aws:lambda:us-east-1:123456789012:function:SecretsManagerRDSMySQLRotationSingleUser" \
  --rotation-rules '{"AutomaticallyAfterDays": 30}'

# Check rotation status
aws secretsmanager describe-secret \
  --secret-id "prod/myapp/rds-credentials" \
  --query "{RotationEnabled:RotationEnabled,NextRotationDate:NextRotationDate,LastRotatedDate:LastRotatedDate}"

Custom Rotation Lambda Anatomy

For non-RDS targets (Stripe API keys, internal service tokens), you implement a four-step rotation Lambda. Secrets Manager calls your Lambda four times per rotation cycle, each time with a different Step value:

import boto3
import json
import logging
import random
import string

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    arn = event["SecretId"]
    token = event["ClientRequestToken"]
    step = event["Step"]

    client = boto3.client("secretsmanager")
    metadata = client.describe_secret(SecretId=arn)
    if not metadata["RotationEnabled"]:
        raise ValueError(f"Secret {arn} is not enabled for rotation")

    versions = metadata.get("VersionIdsToStages", {})
    if token not in versions:
        raise ValueError(f"Token {token} is not staged on secret {arn}")
    if "AWSCURRENT" in versions[token]:
        logger.info(f"Token {token} is already AWSCURRENT — no rotation needed")
        return
    if "AWSPENDING" not in versions[token]:
        raise ValueError(f"Token {token} is not staged as AWSPENDING")

    if step == "createSecret":
        create_secret(client, arn, token)
    elif step == "setSecret":
        set_secret(client, arn, token)
    elif step == "testSecret":
        test_secret(client, arn, token)
    elif step == "finishSecret":
        finish_secret(client, arn, token)

def create_secret(client, arn, token):
    """Generate a new credential and store it as AWSPENDING."""
    try:
        client.get_secret_value(SecretId=arn, VersionStage="AWSPENDING",
                                VersionId=token)
        logger.info("createSecret: AWSPENDING already exists — skipping")
        return
    except client.exceptions.ResourceNotFoundException:
        pass

    current = json.loads(
        client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT")["SecretString"]
    )
    new_password = generate_password()
    current["password"] = new_password
    client.put_secret_value(
        SecretId=arn,
        ClientRequestToken=token,
        SecretString=json.dumps(current),
        VersionStages=["AWSPENDING"]
    )
    logger.info("createSecret: AWSPENDING created")

def set_secret(client, arn, token):
    """Apply the new credential in the actual target system (e.g., the database)."""
    pending = json.loads(
        client.get_secret_value(SecretId=arn, VersionStage="AWSPENDING",
                                VersionId=token)["SecretString"]
    )
    # --- Your target-specific logic here ---
    # e.g., connect to DB as admin and issue ALTER USER
    logger.info("setSecret: credential updated in target system")

def test_secret(client, arn, token):
    """Verify the new credential works before promoting it."""
    pending = json.loads(
        client.get_secret_value(SecretId=arn, VersionStage="AWSPENDING",
                                VersionId=token)["SecretString"]
    )
    # --- Connect with new credential, run a test query ---
    logger.info("testSecret: new credential verified")

def finish_secret(client, arn, token):
    """Promote AWSPENDING to AWSCURRENT."""
    metadata = client.describe_secret(SecretId=arn)
    current_version = next(
        v for v, stages in metadata["VersionIdsToStages"].items()
        if "AWSCURRENT" in stages
    )
    if current_version == token:
        logger.info("finishSecret: already AWSCURRENT — skipping")
        return
    client.update_secret_version_stage(
        SecretId=arn,
        VersionStage="AWSCURRENT",
        MoveToVersionId=token,
        RemoveFromVersionId=current_version
    )
    logger.info("finishSecret: rotation complete")

def generate_password(length=32):
    chars = string.ascii_letters + string.digits + "!@#$%^&*"
    return "".join(random.choices(chars, k=length))

Secrets Manager in Terraform

# Create a secret
resource "aws_secretsmanager_secret" "db_credentials" {
  name                    = "prod/myapp/rds-credentials"
  description             = "RDS credentials for myapp production"
  kms_key_id              = aws_kms_key.app_key.arn
  recovery_window_in_days = 7

  tags = {
    Environment = "production"
    Service     = "myapp"
    ManagedBy   = "terraform"
  }
}

resource "aws_secretsmanager_secret_version" "db_credentials" {
  secret_id = aws_secretsmanager_secret.db_credentials.id
  secret_string = jsonencode({
    username = var.db_username
    password = var.db_password
    host     = aws_db_instance.main.address
    port     = 5432
    dbname   = "myapp"
    engine   = "postgres"
  })
}

# Enable automatic rotation
resource "aws_secretsmanager_secret_rotation" "db_credentials" {
  secret_id           = aws_secretsmanager_secret.db_credentials.id
  rotation_lambda_arn = aws_lambda_function.rotation.arn

  rotation_rules {
    automatically_after_days = 30
  }
}

Multi-Region Replication

For globally distributed applications, Secrets Manager can replicate a secret to secondary regions. Replicas are read-only copies that always reflect the primary's AWSCURRENT version. This is especially valuable for multi-region active-passive RDS clusters where both regions must access the same database credential:

aws secretsmanager replicate-secret-to-regions \
  --secret-id "prod/myapp/rds-credentials" \
  --add-replica-regions '[
    {"Region": "eu-west-1", "KmsKeyId": "arn:aws:kms:eu-west-1:123456789012:key/mrk-eu123"},
    {"Region": "ap-southeast-1"}
  ]'

4. Decision Flowchart: When to Use Each

Use this decision tree every time you need to store a value in AWS. It handles 99% of real-world cases:

Decision Flowchart

Does the value need automatic rotation?
  ├─ YESSecrets Manager
  │    (DB passwords, API keys with rotation endpoints, JWT signing keys)
  └─ NO → Continue...

Does the value need to be shared across AWS accounts?
  ├─ YESSecrets Manager (resource-based policies)
  └─ NO → Continue...

Does the value need multi-region replication?
  ├─ YESSecrets Manager
  └─ NO → Continue...

Is the value sensitive (credential, API key, token)?
  ├─ YES, but static/rarely changedParameter Store SecureString (free tier)
  └─ NO — plain configParameter Store String (free tier)

Is the value a list (comma-separated)?
  ├─ YESParameter Store StringList
  └─ NO → Parameter Store String

To summarize in plain language: use Parameter Store for application configuration (feature flags, database hostnames, environment settings) and static credentials where you don't need automatic rotation. Use Secrets Manager whenever credentials must rotate on a schedule, must be shared across accounts, or must replicate globally.

The hybrid pattern: Many mature AWS architectures use both services together. Non-sensitive config lives in Parameter Store (free, hierarchical, easy to browse). RDS passwords, third-party API keys, and JWT secrets live in Secrets Manager (auto-rotation, cross-account sharing). Applications use a single abstraction layer that resolves from either service based on the ARN prefix.

5. Accessing Secrets from Lambda (Python boto3)

Lambda functions are the most common consumer of both Parameter Store and Secrets Manager. The critical rule: never retrieve a secret on every invocation. Lambda execution environments are reused across warm invocations — cache secrets in module-level variables and refresh them only when they expire or when you receive an authentication failure.

The Environment Variable Anti-Pattern

A common mistake is putting secrets in Lambda environment variables. This is problematic because: the secret is visible in the Lambda console, it's stored in the Lambda configuration (not just in KMS), and it's baked in at deploy time — if the secret rotates, your Lambda function still has the old value.

# ANTI-PATTERN — do not do this for sensitive values
resource "aws_lambda_function" "api" {
  environment {
    variables = {
      DB_PASSWORD = var.db_password   # BAD: visible in console, stale on rotation
    }
  }
}

Correct Pattern: Cached Retrieval at Cold Start

import boto3
import json
import time
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Module-level cache — persists across warm invocations
_secret_cache = {}
_param_cache = {}
CACHE_TTL = 300  # 5 minutes — shorter than rotation period

def get_secret(secret_name: str, force_refresh: bool = False) -> dict:
    """Retrieve a secret from Secrets Manager with in-process TTL cache."""
    now = time.time()
    cached = _secret_cache.get(secret_name)
    if cached and not force_refresh and (now - cached["ts"]) < CACHE_TTL:
        return cached["value"]

    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId=secret_name)
    value = json.loads(response["SecretString"])
    _secret_cache[secret_name] = {"value": value, "ts": now}
    logger.info(f"Refreshed secret from Secrets Manager: {secret_name}")
    return value

def get_parameter(param_name: str, force_refresh: bool = False) -> str:
    """Retrieve a parameter from SSM Parameter Store with in-process TTL cache."""
    now = time.time()
    cached = _param_cache.get(param_name)
    if cached and not force_refresh and (now - cached["ts"]) < CACHE_TTL:
        return cached["value"]

    ssm = boto3.client("ssm")
    response = ssm.get_parameter(Name=param_name, WithDecryption=True)
    value = response["Parameter"]["Value"]
    _param_cache[param_name] = {"value": value, "ts": now}
    return value

def lambda_handler(event, context):
    # Cold start: retrieve and cache
    db_creds = get_secret("prod/myapp/rds-credentials")
    api_key = get_parameter("/myapp/prod/stripe-api-key")

    # Use credentials — if authentication fails, force a refresh and retry once
    try:
        result = call_database(db_creds["host"], db_creds["username"],
                               db_creds["password"])
    except AuthenticationError:
        logger.warning("Auth failure — refreshing secret and retrying")
        db_creds = get_secret("prod/myapp/rds-credentials", force_refresh=True)
        result = call_database(db_creds["host"], db_creds["username"],
                               db_creds["password"])
    return {"statusCode": 200, "body": json.dumps(result)}

AWS Parameters and Secrets Lambda Extension

AWS offers a Lambda layer called the Parameters and Secrets Lambda Extension (ARN available per region on the AWS docs). This extension runs as a sidecar process that caches secrets and parameters via an HTTP endpoint on localhost, so your function code makes a local HTTP call instead of a network call to AWS endpoints:

import urllib.request
import json
import os

def get_secret_via_extension(secret_id: str) -> dict:
    """Use the Lambda extension cache layer — zero boto3 calls."""
    port = os.environ.get("PARAMETERS_SECRETS_EXTENSION_HTTP_PORT", "2773")
    token = os.environ["AWS_SESSION_TOKEN"]
    url = f"http://localhost:{port}/secretsmanager/get?secretId={secret_id}"
    req = urllib.request.Request(url, headers={"X-Aws-Parameters-Secrets-Token": token})
    with urllib.request.urlopen(req) as resp:
        data = json.loads(resp.read())
    return json.loads(data["SecretString"])
Extension vs boto3 caching: The Lambda extension layer is the better choice for high-TPS functions where you want the extension to manage the cache lifecycle. For lower-volume functions with complex cache invalidation logic (e.g., retry on auth failure), module-level caching with boto3 gives you more control.

6. Accessing Secrets from ECS and EKS

ECS Task Definition — Native Secret Injection

ECS Fargate and EC2 launch types support injecting secrets directly into container environment variables via the secrets field in a task definition. The ECS agent retrieves the value before starting the container, so your application code simply reads a standard environment variable:

{
  "family": "myapp-task",
  "containerDefinitions": [
    {
      "name": "myapp",
      "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
      "environment": [
        {"name": "DB_HOST",   "value": "mydb.cluster-xyz.us-east-1.rds.amazonaws.com"},
        {"name": "DB_PORT",   "value": "5432"}
      ],
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/myapp/rds-credentials:password::"
        },
        {
          "name": "STRIPE_API_KEY",
          "valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/myapp/prod/stripe-api-key"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/myapp",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ],
  "taskRoleArn": "arn:aws:iam::123456789012:role/myapp-task-role",
  "executionRoleArn": "arn:aws:iam::123456789012:role/myapp-execution-role"
}

The executionRoleArn must have permission to call both secretsmanager:GetSecretValue and ssm:GetParameters. The task role (taskRoleArn) is what your application code uses at runtime — keep these two roles separate for least-privilege.

ECS secret caching caveat: ECS injects secrets at task startup. If a secret rotates while your task is running, the task continues using the old value until it restarts. For long-running ECS services, use the boto3/extension caching pattern in your application code to periodically refresh credentials rather than relying solely on ECS injection.

EKS — External Secrets Operator

Kubernetes workloads on EKS should use the External Secrets Operator (ESO) to sync AWS secrets into native Kubernetes Secret objects. ESO polls on a configurable refreshInterval and automatically updates the Kubernetes Secret when the AWS secret changes:

---
# ClusterSecretStore — defines the AWS connection (once, cluster-wide)
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager-store
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets
---
# ExternalSecret — syncs a specific secret into a namespace
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-rds-secret
  namespace: production
spec:
  refreshInterval: 5m
  secretStoreRef:
    name: aws-secrets-manager-store
    kind: ClusterSecretStore
  target:
    name: myapp-rds-credentials
    creationPolicy: Owner
  data:
    - secretKey: username
      remoteRef:
        key: prod/myapp/rds-credentials
        property: username
    - secretKey: password
      remoteRef:
        key: prod/myapp/rds-credentials
        property: password
    - secretKey: host
      remoteRef:
        key: prod/myapp/rds-credentials
        property: host

For Parameter Store values on EKS, ESO supports the same pattern with service: ParameterStore in the ClusterSecretStore. See the EKS guide for IRSA (IAM Roles for Service Accounts) configuration needed by ESO.

7. Cross-Account Secret Sharing

A common enterprise architecture is a dedicated security account that centrally manages all production credentials, with workload accounts (dev, staging, prod) reading secrets via cross-account IAM. Secrets Manager supports this natively through resource-based policies; Parameter Store does not — its values are account-local only.

Three Components Required

Cross-account secret access requires three pieces to align:

  1. A resource-based policy on the secret in the security account granting access to the workload account's role
  2. A KMS key policy in the security account granting kms:Decrypt to the workload role
  3. An IAM identity policy on the workload role allowing it to call secretsmanager:GetSecretValue

Resource-Based Policy (Security Account)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowWorkloadAccountRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::WORKLOAD-ACCOUNT-ID:role/AppRole",
          "arn:aws:iam::WORKLOAD-ACCOUNT-ID:role/LambdaRole"
        ]
      },
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalOrgID": "o-yourorgid123"
        }
      }
    }
  ]
}

# Apply the resource policy
aws secretsmanager put-resource-policy \
  --secret-id "prod/shared/payment-processor-key" \
  --resource-policy file://cross-account-policy.json \
  --block-public-policy \
  --profile security-account

KMS Key Policy Addition (Security Account)

{
  "Sid": "AllowCrossAccountDecrypt",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::WORKLOAD-ACCOUNT-ID:role/AppRole"
  },
  "Action": ["kms:Decrypt", "kms:DescribeKey"],
  "Resource": "*"
}

Consuming Cross-Account Secret (Workload Account)

import boto3
import json

def get_cross_account_secret(secret_arn: str, region: str = "us-east-1") -> dict:
    """
    Retrieve a secret from a central security account.
    The calling role must have been granted access via the secret's resource policy.
    No STS assume_role needed — the workload role's IAM policy + the secret's
    resource policy together grant access.
    """
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_arn)
    return json.loads(response["SecretString"])

# Usage — note: full ARN required for cross-account access
secret = get_cross_account_secret(
    "arn:aws:secretsmanager:us-east-1:SECURITY-ACCOUNT:secret:prod/shared/payment-processor-key-AbCdEf"
)
print(f"API key prefix: {secret['api_key'][:8]}...")
IAM note: Cross-account secret access follows Secrets Manager's dual-permission model — the workload role needs a matching IAM identity policy AND the secret's resource policy must grant access. See the IAM Roles and Policies guide for the complete permission evaluation logic.

8. Cost Comparison with Real Numbers

Cost is often the deciding factor when Parameter Store's free tier is sufficient. Let's work through concrete scenarios.

Scenario A: Small Startup (10 services, no rotation)

ParameterParameter StoreSecrets Manager
10 DB passwords (SecureString)$0.00$4.00/month
50 config strings$0.00$20.00/month
500k API calls/month$0.00 (free tier)$2.50/month
Total$0.00$26.50/month

Verdict: If you don't need auto-rotation or cross-account access, Parameter Store is free for this workload. Secrets Manager costs $318/year for the same data with no additional benefit.

Scenario B: Mid-Size Company (50 rotating credentials)

ItemParameter StoreSecrets Manager
50 DB/API credentials (rotating)$0.00 (no rotation available)$20.00/month
200 config parameters$0.00$80.00/month
Rotation Lambda invocations (50 × 12/year)N/A~$0.03/month
5M API calls/month$25.00 (Advanced tier)$25.00
Total$25/month (no rotation)$125/month (with rotation)

Verdict: Secrets Manager costs $100/month more, but that buys you automatic credential rotation. For a mid-size company, a single breach caused by a stale credential will cost far more than $1,200/year — the $0.40/secret/month is insurance, not overhead.

When Parameter Store's Advanced Tier Makes Sense

If you have more than 10,000 parameters or need values larger than 4 KB, you'll pay $0.05 per 10,000 API interactions for Advanced tier. At 1 million calls/month that's $5/month — still far cheaper than Secrets Manager for non-rotating values. Advanced tier also unlocks parameter policies, which let you set expiration dates and receive SNS notifications when a parameter hasn't been updated in a defined period — a poor man's rotation reminder.

# Set an expiration policy on an Advanced parameter (requires Advanced tier)
aws ssm put-parameter \
  --name "/myapp/prod/temp-access-token" \
  --value "temp-token-xyz" \
  --type SecureString \
  --tier Advanced \
  --policies '[
    {
      "Type": "Expiration",
      "Version": "1.0",
      "Attributes": {
        "Timestamp": "2026-12-31T00:00:00.000Z"
      }
    },
    {
      "Type": "ExpirationNotification",
      "Version": "1.0",
      "Attributes": {
        "Before": "15",
        "Unit": "Days"
      }
    }
  ]'

9. CI/CD Integration

Secrets must be accessible during build and deploy pipelines — for connecting to databases during migration runs, authenticating to artifact registries, or setting environment-specific config in deployment manifests. Both services integrate cleanly with AWS CodePipeline and GitHub Actions.

AWS CodePipeline — IAM Role Access

CodePipeline stages (CodeBuild specifically) run under a service role. Grant that role access to Parameter Store and Secrets Manager, then read values in your buildspec:

# buildspec.yml
version: 0.2
phases:
  pre_build:
    commands:
      # Read non-sensitive config from Parameter Store
      - export DB_HOST=$(aws ssm get-parameter --name "/myapp/prod/db-host" --query "Parameter.Value" --output text)
      - export APP_ENV=$(aws ssm get-parameter --name "/myapp/prod/environment" --query "Parameter.Value" --output text)

      # Read sensitive credentials from Secrets Manager
      - export DB_CREDS=$(aws secretsmanager get-secret-value --secret-id "prod/myapp/rds-credentials" --query "SecretString" --output text)
      - export DB_PASSWORD=$(echo $DB_CREDS | python3 -c "import sys,json; print(json.load(sys.stdin)['password'])")

  build:
    commands:
      - echo "Running DB migrations against $DB_HOST"
      - python manage.py migrate
      - docker build -t myapp:$CODEBUILD_RESOLVED_SOURCE_VERSION .

  post_build:
    commands:
      - docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:$CODEBUILD_RESOLVED_SOURCE_VERSION

GitHub Actions — OIDC + AWS Secrets

GitHub Actions supports OIDC federation with AWS, letting your workflow assume an IAM role without storing AWS credentials as GitHub secrets. After configuring the OIDC identity provider in your AWS account, use the official AWS credentials action and then retrieve your secrets:

name: Deploy to Production

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
          aws-region: us-east-1

      - name: Read config from Parameter Store
        run: |
          echo "DB_HOST=$(aws ssm get-parameter \
            --name '/myapp/prod/db-host' \
            --query 'Parameter.Value' --output text)" >> $GITHUB_ENV
          echo "APP_VERSION=$(aws ssm get-parameter \
            --name '/myapp/prod/app-version' \
            --query 'Parameter.Value' --output text)" >> $GITHUB_ENV

      - name: Read credentials from Secrets Manager
        run: |
          SECRET=$(aws secretsmanager get-secret-value \
            --secret-id 'prod/myapp/deploy-credentials' \
            --query 'SecretString' --output text)
          echo "DEPLOY_TOKEN=$(echo $SECRET | jq -r '.token')" >> $GITHUB_ENV

      - name: Deploy application
        run: ./scripts/deploy.sh
Security note: Never print secret values in CI/CD logs. GitHub Actions masks values set via $GITHUB_ENV automatically, but only if the value was sourced from a GitHub secret. Values fetched from AWS at runtime are not masked — use a wrapper script that handles secrets without echoing them to stdout. See the CodePipeline CI/CD guide for secure pipeline patterns.

10. Best Practices

Naming Conventions

Use environment-scoped hierarchical names consistently across both services. This makes IAM policies manageable and browsing in the console intuitive:

# Parameter Store — path hierarchy
/myapp/prod/db-host
/myapp/prod/db-port
/myapp/prod/db-name
/myapp/prod/db-password         # SecureString
/myapp/prod/feature-flag-new-ui  # plain String, "true"/"false"

/shared/prod/internal-ca-cert   # shared across services

# Secrets Manager — flat names by convention
prod/myapp/rds-credentials
prod/myapp/stripe-api-key
prod/shared/internal-signing-key
staging/myapp/rds-credentials

Least-Privilege IAM Policies

Never grant ssm:* or secretsmanager:*. Scope policies to the specific paths and actions each role requires:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOwnParameters",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameter",
        "ssm:GetParameters",
        "ssm:GetParametersByPath"
      ],
      "Resource": "arn:aws:ssm:us-east-1:123456789012:parameter/myapp/prod/*"
    },
    {
      "Sid": "ReadOwnSecrets",
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/myapp/*"
    },
    {
      "Sid": "DecryptWithAppKey",
      "Effect": "Allow",
      "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
      "Resource": "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": [
            "ssm.us-east-1.amazonaws.com",
            "secretsmanager.us-east-1.amazonaws.com"
          ]
        }
      }
    }
  ]
}

Auditing with CloudTrail

All Parameter Store and Secrets Manager API calls are recorded in CloudTrail. Key events to alert on:

  • GetSecretValue / GetParameter from unexpected IAM principals or IP ranges — potential credential exfiltration
  • DeleteSecret / DeleteParameter — always alert; accidental deletions are catastrophic
  • PutSecretValue / PutParameter from non-automation roles — unauthorized secret modification
  • RotateSecret failures — rotation failure leaves your secret stale
# CloudWatch metric filter for secret deletions
aws logs put-metric-filter \
  --log-group-name "aws-cloudtrail-logs" \
  --filter-name "SecretOrParamDeletion" \
  --filter-pattern '{
    ($.eventSource = "secretsmanager.amazonaws.com" && $.eventName = "DeleteSecret") ||
    ($.eventSource = "ssm.amazonaws.com" && $.eventName = "DeleteParameter")
  }' \
  --metric-transformations \
    metricName=SecretDeletions,metricNamespace=Security/Secrets,metricValue=1,defaultValue=0

Key Management: aws/ssm vs CMK

Parameter Store SecureString parameters can use the AWS-managed aws/ssm key (free, no key management overhead) or a customer-managed key (CMK). Use a CMK when you need cross-account sharing, key rotation control, or when compliance requires you to own the key material. Secrets Manager always encrypts — the default aws/secretsmanager key is sufficient unless you need cross-account access (which requires a CMK as cross-account principals can't use AWS-managed keys).

Deletion Protection

Always set a recovery window on Secrets Manager secrets to prevent accidental permanent deletion. The default is 30 days; the minimum is 7 days:

# Safe deletion — enters a 14-day recovery window
aws secretsmanager delete-secret \
  --secret-id "prod/myapp/rds-credentials" \
  --recovery-window-in-days 14

# Restore before the window expires
aws secretsmanager restore-secret \
  --secret-id "prod/myapp/rds-credentials"

# Force immediate deletion (DANGEROUS — irreversible)
aws secretsmanager delete-secret \
  --secret-id "prod/myapp/rds-credentials" \
  --force-delete-without-recovery
Summary: Use Parameter Store for static configuration and non-rotating credentials (free, hierarchical, sufficient for most config). Use Secrets Manager when you need automatic rotation, cross-account sharing, or multi-region replication. The $0.40/secret/month premium for Secrets Manager is worth it for any credential that changes — the cost of a breach far exceeds the cost of proper rotation infrastructure.

Related Articles

Stay Updated

Get the latest AWS and cloud engineering articles delivered to your inbox.