AWS Parameter Store vs Secrets Manager: When to Use Which
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 purpose | Configuration + non-rotating secrets | Credentials requiring automatic rotation |
| Cost per secret | Free (Standard tier) / $0.05/10k API calls (Advanced) | $0.40/secret/month + $0.05/10k API calls |
| Free tier | 10,000 standard parameters free forever | 30-day free trial only |
| Value size limit | 4 KB (Standard) / 8 KB (Advanced) | 65,536 bytes (~64 KB) |
| Parameter/secret types | String, StringList, SecureString | Key-value JSON or plaintext string |
| Automatic rotation | No (manual only) | Yes — native RDS/Aurora/Redshift rotators + custom Lambda |
| Rotation schedule | N/A | Days interval or cron expression |
| Versioning | Yes — up to 100 versions per parameter, labeled | Yes — AWSCURRENT / AWSPENDING / AWSPREVIOUS staging labels |
| Hierarchies / paths | Yes — /app/prod/db-password style paths, GetParametersByPath | No native hierarchy; naming convention only |
| Cross-account access | No (parameter policies don't support resource-based policies) | Yes — resource-based policy on secret |
| Multi-region replication | No | Yes — replica secrets in up to 100 regions |
| KMS encryption | Optional (SecureString type); Standard params can use SSM-managed key | Always encrypted; CMK or aws/secretsmanager key |
| VPC endpoint | Yes (ssm endpoint) | Yes (secretsmanager endpoint) |
| CloudTrail auditing | Yes | Yes |
| Parameter policies | Yes — expiration, no-change notification, expiration notification (Advanced only) | N/A (rotation replaces this) |
| Throughput | 40 TPS standard / 100 TPS advanced (per region) | 10,000 TPS per region |
| ECS native injection | Yes (valueFrom with SSM ARN) | Yes (valueFrom with Secrets Manager ARN) |
| Lambda extension layer | AWS Parameters and Secrets Lambda Extension | AWS Parameters and Secrets Lambda Extension |
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:
| Attribute | Standard | Advanced |
|---|---|---|
| Max parameter count per account/region | 10,000 | 100,000 |
| Max value size | 4 KB | 8 KB |
| Parameter policies (expiration, notification) | No | Yes |
| Cost | Free | $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
}
}
}
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:
Does the value need automatic rotation?
├─ YES → Secrets Manager
│ (DB passwords, API keys with rotation endpoints, JWT signing keys)
└─ NO → Continue...
Does the value need to be shared across AWS accounts?
├─ YES → Secrets Manager (resource-based policies)
└─ NO → Continue...
Does the value need multi-region replication?
├─ YES → Secrets Manager
└─ NO → Continue...
Is the value sensitive (credential, API key, token)?
├─ YES, but static/rarely changed → Parameter Store SecureString (free tier)
└─ NO — plain config → Parameter Store String (free tier)
Is the value a list (comma-separated)?
├─ YES → Parameter 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.
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"])
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.
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:
- A resource-based policy on the secret in the security account granting access to the workload account's role
- A KMS key policy in the security account granting
kms:Decryptto the workload role - 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]}...")
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)
| Parameter | Parameter Store | Secrets 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)
| Item | Parameter Store | Secrets 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
$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