Security

Managing Secrets in ECS: Parameter Store vs. Secrets Manager - A Complete Guide

Zak Kann
AWSECSSecrets ManagementSecurityParameter StoreSecrets ManagerDevOps

Key takeaways

  • Parameter Store costs $0.05/10K API calls; Secrets Manager costs $0.40/secret/month plus $0.05/10K API calls
  • Secrets Manager provides automatic rotation for RDS, Redshift, and DocumentDB; Parameter Store requires custom Lambda functions
  • Both integrate natively with ECS task definitions for secure secret injection
  • Parameter Store supports hierarchical organization and cross-account access; Secrets Manager excels at credential rotation
  • Choose Parameter Store for configuration and API keys; Secrets Manager for database credentials requiring rotation

Your ECS task fails to start. The error message is cryptic: "CannotPullContainerError: pull access denied." You check the ECR credentials—they're hardcoded in the task definition from six months ago when the team was moving fast. The credentials rotated. Your production deployment is blocked.

This scenario plays out across organizations managing secrets in container workloads. The solution isn't just storing secrets securely—it's building a secrets management strategy that balances security, cost, operational complexity, and developer experience.

AWS provides two services for secrets management: Systems Manager Parameter Store and Secrets Manager. Both integrate with ECS, both support encryption, and both solve the "don't hardcode credentials" problem. But they're designed for different use cases, with different cost models and operational characteristics.

This guide provides a comprehensive comparison to help you choose the right service—or combination of both—for your ECS workloads.

Understanding the Services

AWS Systems Manager Parameter Store

Parameter Store is a hierarchical key-value store designed for configuration management and secrets storage.

Key Characteristics:

  • Pricing: $0.05 per 10,000 API calls (standard parameters); advanced parameters cost $0.05/parameter/month
  • Value size: 4KB (standard), 8KB (advanced)
  • Encryption: Optional KMS encryption
  • Versioning: Built-in version history
  • Organization: Hierarchical paths (e.g., /prod/api/database/password)
  • Rotation: Manual or custom Lambda functions
  • Cross-account: Supports cross-account access with resource policies

Best for:

  • Application configuration
  • API keys and tokens
  • Feature flags
  • Non-rotating secrets
  • Cost-sensitive workloads

AWS Secrets Manager

Secrets Manager is a dedicated secrets management service with built-in rotation capabilities.

Key Characteristics:

  • Pricing: $0.40/secret/month + $0.05 per 10,000 API calls
  • Value size: 65,536 bytes (64KB)
  • Encryption: Always encrypted with KMS
  • Versioning: Automatic version management with staging labels
  • Organization: Flat namespace with tags
  • Rotation: Automatic rotation with pre-built Lambda functions for RDS, Redshift, DocumentDB
  • Cross-region replication: Built-in multi-region replication

Best for:

  • Database credentials
  • Third-party API credentials requiring rotation
  • Secrets requiring automatic rotation
  • Multi-region deployments
  • Compliance requirements (SOC 2, PCI-DSS)

Cost Analysis

Scenario 1: Small Application (10 Secrets)

Parameter Store:

10 secrets (standard parameters)
1,000 retrievals/day = 30,000/month
Cost: 30,000 / 10,000 × $0.05 = $0.15/month

Secrets Manager:

10 secrets × $0.40/month = $4.00
30,000 API calls / 10,000 × $0.05 = $0.15
Total: $4.15/month

Difference: Secrets Manager costs $4.00 more/month (2,667% increase)

Scenario 2: Medium Application (100 Secrets)

Parameter Store:

100 secrets (standard parameters)
10,000 retrievals/day = 300,000/month
Cost: 300,000 / 10,000 × $0.05 = $1.50/month

Secrets Manager:

100 secrets × $0.40/month = $40.00
300,000 API calls / 10,000 × $0.05 = $1.50
Total: $41.50/month

Difference: Secrets Manager costs $40.00 more/month

Scenario 3: Large Application (1,000 Secrets, High Traffic)

Parameter Store:

1,000 secrets (standard parameters)
100,000 retrievals/day = 3,000,000/month
Cost: 3,000,000 / 10,000 × $0.05 = $15.00/month

Secrets Manager:

1,000 secrets × $0.40/month = $400.00
3,000,000 API calls / 10,000 × $0.05 = $15.00
Total: $415.00/month

Difference: Secrets Manager costs $400.00 more/month

Cost Optimization: Caching

Both services support client-side caching to reduce API calls.

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
 
// Simple in-memory cache
const secretCache = new Map<string, { value: string; expiry: number }>();
 
async function getSecret(secretName: string, ttl: number = 300): Promise<string> {
  const cached = secretCache.get(secretName);
 
  if (cached && cached.expiry > Date.now()) {
    return cached.value;
  }
 
  const client = new SecretsManagerClient({});
  const response = await client.send(new GetSecretValueCommand({ SecretId: secretName }));
 
  const value = response.SecretString!;
  secretCache.set(secretName, {
    value,
    expiry: Date.now() + (ttl * 1000),
  });
 
  return value;
}

Cost impact with 5-minute cache:

  • Before: 100,000 calls/day = 3M/month = $15
  • After: 100,000 / (5 * 12) = 1,667 calls/day = 50,000/month = $0.25
  • Savings: $14.75/month (98% reduction)

ECS Integration Patterns

Pattern 1: Task Definition Secrets (Recommended)

ECS supports native secret injection via task definitions.

Using Parameter Store:

{
  "family": "api-service",
  "containerDefinitions": [{
    "name": "app",
    "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest",
    "secrets": [
      {
        "name": "DATABASE_PASSWORD",
        "valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/api/db/password"
      },
      {
        "name": "API_KEY",
        "valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/api/external/key"
      }
    ],
    "environment": [
      {
        "name": "DATABASE_HOST",
        "value": "db.example.com"
      }
    ]
  }],
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole"
}

Using Secrets Manager:

{
  "family": "api-service",
  "containerDefinitions": [{
    "name": "app",
    "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest",
    "secrets": [
      {
        "name": "DATABASE_PASSWORD",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/api/db-AbCdEf:password::"
      },
      {
        "name": "DATABASE_USERNAME",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/api/db-AbCdEf:username::"
      }
    ]
  }],
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole"
}

Terraform Configuration:

# Parameter Store secret
resource "aws_ssm_parameter" "db_password" {
  name        = "/prod/api/db/password"
  description = "Database password for API service"
  type        = "SecureString"
  value       = var.db_password
 
  tags = {
    Environment = "production"
    Service     = "api"
  }
}
 
# Secrets Manager secret
resource "aws_secretsmanager_secret" "db_credentials" {
  name        = "prod/api/db"
  description = "Database credentials for API service"
 
  tags = {
    Environment = "production"
    Service     = "api"
  }
}
 
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.endpoint
    port     = 5432
  })
}
 
# ECS Task Execution Role with permissions
resource "aws_iam_role" "ecs_execution" {
  name = "ecsTaskExecutionRole"
 
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ecs-tasks.amazonaws.com"
      }
    }]
  })
}
 
# Parameter Store access
resource "aws_iam_role_policy" "parameter_store_access" {
  name = "parameter-store-access"
  role = aws_iam_role.ecs_execution.id
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "ssm:GetParameters",
        "ssm:GetParameter",
      ]
      Resource = [
        "arn:aws:ssm:${var.region}:${var.account_id}:parameter/prod/api/*"
      ]
    }, {
      Effect = "Allow"
      Action = [
        "kms:Decrypt"
      ]
      Resource = [
        aws_kms_key.secrets.arn
      ]
    }]
  })
}
 
# Secrets Manager access
resource "aws_iam_role_policy" "secrets_manager_access" {
  name = "secrets-manager-access"
  role = aws_iam_role.ecs_execution.id
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "secretsmanager:GetSecretValue"
      ]
      Resource = [
        aws_secretsmanager_secret.db_credentials.arn
      ]
    }, {
      Effect = "Allow"
      Action = [
        "kms:Decrypt"
      ]
      Resource = [
        aws_kms_key.secrets.arn
      ]
    }]
  })
}
 
# ECS Task Definition
resource "aws_ecs_task_definition" "api" {
  family                   = "api-service"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
 
  container_definitions = jsonencode([{
    name  = "app"
    image = "${aws_ecr_repository.api.repository_url}:latest"
 
    secrets = [
      {
        name      = "DATABASE_PASSWORD"
        valueFrom = "${aws_ssm_parameter.db_password.arn}"
      },
      {
        name      = "DATABASE_CREDENTIALS"
        valueFrom = "${aws_secretsmanager_secret.db_credentials.arn}"
      }
    ]
 
    environment = [
      {
        name  = "NODE_ENV"
        value = "production"
      }
    ]
 
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = "/ecs/api-service"
        "awslogs-region"        = var.region
        "awslogs-stream-prefix" = "ecs"
      }
    }
  }])
}

Pattern 2: Application-Level Secret Retrieval

For dynamic secret retrieval or runtime updates without container restart.

Parameter Store Client:

import { SSMClient, GetParameterCommand, GetParametersByPathCommand } from '@aws-sdk/client-ssm';
 
const ssm = new SSMClient({});
 
// Get single parameter
export async function getParameter(name: string): Promise<string> {
  const response = await ssm.send(new GetParameterCommand({
    Name: name,
    WithDecryption: true,
  }));
 
  return response.Parameter!.Value!;
}
 
// Get all parameters under a path
export async function getParametersByPath(path: string): Promise<Record<string, string>> {
  const response = await ssm.send(new GetParametersByPathCommand({
    Path: path,
    Recursive: true,
    WithDecryption: true,
  }));
 
  const params: Record<string, string> = {};
  for (const param of response.Parameters || []) {
    // Extract parameter name without path prefix
    const key = param.Name!.split('/').pop()!;
    params[key] = param.Value!;
  }
 
  return params;
}
 
// Usage
const dbPassword = await getParameter('/prod/api/db/password');
const allApiSecrets = await getParametersByPath('/prod/api/');

Secrets Manager Client:

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
 
const secretsManager = new SecretsManagerClient({});
 
// Get secret value
export async function getSecret(secretId: string): Promise<any> {
  const response = await secretsManager.send(new GetSecretValueCommand({
    SecretId: secretId,
  }));
 
  // Parse JSON secret
  if (response.SecretString) {
    return JSON.parse(response.SecretString);
  }
 
  // Handle binary secret
  if (response.SecretBinary) {
    return Buffer.from(response.SecretBinary).toString('utf-8');
  }
 
  throw new Error('Secret not found');
}
 
// Usage
const dbCredentials = await getSecret('prod/api/db');
console.log(`Connecting to ${dbCredentials.host}:${dbCredentials.port}`);

Pattern 3: Hybrid Approach (Best Practice)

Use both services for their strengths.

# Secrets Manager for database credentials (automatic rotation)
resource "aws_secretsmanager_secret" "rds_master" {
  name        = "prod/rds/master"
  description = "RDS master credentials with automatic rotation"
}
 
resource "aws_secretsmanager_secret_rotation" "rds_master" {
  secret_id           = aws_secretsmanager_secret.rds_master.id
  rotation_lambda_arn = aws_lambda_function.rotate_secret.arn
 
  rotation_rules {
    automatically_after_days = 30
  }
}
 
# Parameter Store for configuration and API keys
resource "aws_ssm_parameter" "stripe_api_key" {
  name  = "/prod/api/stripe/api_key"
  type  = "SecureString"
  value = var.stripe_api_key
}
 
resource "aws_ssm_parameter" "sendgrid_api_key" {
  name  = "/prod/api/sendgrid/api_key"
  type  = "SecureString"
  value = var.sendgrid_api_key
}
 
resource "aws_ssm_parameter" "feature_flags" {
  name  = "/prod/api/features/experimental_checkout"
  type  = "String"
  value = "enabled"
}

Secret Rotation Strategies

Secrets Manager: Automatic Rotation

RDS/Aurora Rotation (Built-in):

resource "aws_secretsmanager_secret" "rds_credentials" {
  name = "prod/rds/app-user"
}
 
resource "aws_secretsmanager_secret_version" "rds_credentials" {
  secret_id = aws_secretsmanager_secret.rds_credentials.id
  secret_string = jsonencode({
    username = "app_user"
    password = random_password.db_password.result
    engine   = "postgres"
    host     = aws_db_instance.main.endpoint
    port     = 5432
    dbname   = "production"
  })
}
 
# Automatic rotation every 30 days
resource "aws_secretsmanager_secret_rotation" "rds_credentials" {
  secret_id           = aws_secretsmanager_secret.rds_credentials.id
  rotation_lambda_arn = aws_serverlessapplicationrepository_cloudformation_stack.rotator.outputs.RotationLambdaARN
 
  rotation_rules {
    automatically_after_days = 30
  }
}
 
# Serverless Application Repository rotation function
resource "aws_serverlessapplicationrepository_cloudformation_stack" "rotator" {
  name           = "SecretsManagerRDSPostgreSQLRotationSingleUser"
  application_id = "arn:aws:serverlessrepo:us-east-1:297356227924:applications/SecretsManagerRDSPostgreSQLRotationSingleUser"
 
  parameters = {
    endpoint            = "https://secretsmanager.us-east-1.amazonaws.com"
    functionName        = "SecretsManagerRDSPostgreSQLRotation"
    vpcSubnetIds        = join(",", [aws_subnet.private_a.id, aws_subnet.private_b.id])
    vpcSecurityGroupIds = aws_security_group.lambda.id
  }
 
  capabilities = [
    "CAPABILITY_IAM",
    "CAPABILITY_RESOURCE_POLICY",
  ]
}

Custom Rotation Function:

// Lambda function for custom secret rotation
import { SecretsManagerClient, GetSecretValueCommand,
         PutSecretValueCommand, UpdateSecretVersionStageCommand } from '@aws-sdk/client-secrets-manager';
 
const secretsManager = new SecretsManagerClient({});
 
export const handler = async (event: any) => {
  const token = event.Token;
  const secretId = event.SecretId;
  const step = event.Step;
 
  switch (step) {
    case 'createSecret':
      await createSecret(secretId, token);
      break;
    case 'setSecret':
      await setSecret(secretId, token);
      break;
    case 'testSecret':
      await testSecret(secretId, token);
      break;
    case 'finishSecret':
      await finishSecret(secretId, token);
      break;
    default:
      throw new Error(`Invalid step: ${step}`);
  }
};
 
async function createSecret(secretId: string, token: string) {
  // Generate new secret value
  const newPassword = generateSecurePassword();
 
  // Store new secret with AWSPENDING label
  await secretsManager.send(new PutSecretValueCommand({
    SecretId: secretId,
    ClientRequestToken: token,
    SecretString: JSON.stringify({ password: newPassword }),
    VersionStages: ['AWSPENDING'],
  }));
}
 
async function setSecret(secretId: string, token: string) {
  // Get the pending secret
  const response = await secretsManager.send(new GetSecretValueCommand({
    SecretId: secretId,
    VersionId: token,
    VersionStage: 'AWSPENDING',
  }));
 
  const newSecret = JSON.parse(response.SecretString!);
 
  // Update the external service with new credentials
  await updateExternalServiceCredentials(newSecret.password);
}
 
async function testSecret(secretId: string, token: string) {
  // Get the pending secret
  const response = await secretsManager.send(new GetSecretValueCommand({
    SecretId: secretId,
    VersionId: token,
    VersionStage: 'AWSPENDING',
  }));
 
  const newSecret = JSON.parse(response.SecretString!);
 
  // Test the new credentials
  const isValid = await testExternalServiceConnection(newSecret.password);
 
  if (!isValid) {
    throw new Error('New secret failed validation');
  }
}
 
async function finishSecret(secretId: string, token: string) {
  // Move AWSCURRENT label to new version
  await secretsManager.send(new UpdateSecretVersionStageCommand({
    SecretId: secretId,
    VersionStage: 'AWSCURRENT',
    MoveToVersionId: token,
    RemoveFromVersionId: await getCurrentVersionId(secretId),
  }));
}
 
function generateSecurePassword(): string {
  const crypto = require('crypto');
  return crypto.randomBytes(32).toString('base64');
}

Parameter Store: Manual Rotation

Parameter Store doesn't have built-in rotation, but you can build custom workflows.

// Custom rotation script for Parameter Store
import { SSMClient, PutParameterCommand, GetParameterHistoryCommand } from '@aws-sdk/client-ssm';
import { KMSClient, GenerateRandomCommand } from '@aws-sdk/client-kms';
 
const ssm = new SSMClient({});
const kms = new KMSClient({});
 
async function rotateParameter(parameterName: string): Promise<void> {
  // Generate new secret value
  const randomData = await kms.send(new GenerateRandomCommand({
    NumberOfBytes: 32,
  }));
  const newValue = Buffer.from(randomData.Plaintext!).toString('base64');
 
  // Update parameter (creates new version automatically)
  await ssm.send(new PutParameterCommand({
    Name: parameterName,
    Value: newValue,
    Type: 'SecureString',
    Overwrite: true,
    Description: `Rotated on ${new Date().toISOString()}`,
  }));
 
  console.log(`Rotated parameter: ${parameterName}`);
 
  // Trigger application reload or rolling deployment
  await triggerECSDeployment();
}
 
async function triggerECSDeployment(): Promise<void> {
  // Force new deployment to pick up updated parameter
  const { ECSClient, UpdateServiceCommand } = require('@aws-sdk/client-ecs');
  const ecs = new ECSClient({});
 
  await ecs.send(new UpdateServiceCommand({
    cluster: 'production',
    service: 'api-service',
    forceNewDeployment: true,
  }));
}

Hierarchical Organization (Parameter Store)

Parameter Store's hierarchical structure enables elegant organization.

/prod/
  ├── api/
  │   ├── database/
  │   │   ├── host
  │   │   ├── port
  │   │   ├── username
  │   │   └── password
  │   ├── redis/
  │   │   ├── host
  │   │   └── port
  │   └── external/
  │       ├── stripe_key
  │       └── sendgrid_key
  ├── worker/
  │   └── queue/
  │       ├── url
  │       └── region
  └── shared/
      ├── kms_key_id
      └── log_level

Terraform:

# Database configuration
resource "aws_ssm_parameter" "db_host" {
  name  = "/prod/api/database/host"
  type  = "String"
  value = aws_db_instance.main.endpoint
}
 
resource "aws_ssm_parameter" "db_password" {
  name  = "/prod/api/database/password"
  type  = "SecureString"
  value = random_password.db_password.result
}
 
# Retrieve all database parameters
data "aws_ssm_parameters_by_path" "db_config" {
  path            = "/prod/api/database"
  with_decryption = true
}

Application Code:

// Load all parameters under a path
const dbConfig = await getParametersByPath('/prod/api/database');
 
const pool = new Pool({
  host: dbConfig.host,
  port: parseInt(dbConfig.port),
  user: dbConfig.username,
  password: dbConfig.password,
  database: 'production',
});

Cross-Account Access

Parameter Store Cross-Account

# Account A: Create parameter with resource policy
resource "aws_ssm_parameter" "shared_api_key" {
  name  = "/shared/api/external_service_key"
  type  = "SecureString"
  value = var.api_key
}
 
resource "aws_ssm_parameter_policy" "cross_account" {
  name = aws_ssm_parameter.shared_api_key.name
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "AllowCrossAccountAccess"
      Effect = "Allow"
      Principal = {
        AWS = "arn:aws:iam::222222222222:root"
      }
      Action = [
        "ssm:GetParameter",
        "ssm:GetParameters"
      ]
      Resource = aws_ssm_parameter.shared_api_key.arn
    }]
  })
}
 
# Account B: IAM policy to access parameter in Account A
resource "aws_iam_role_policy" "cross_account_parameter" {
  role = aws_iam_role.ecs_execution.id
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "ssm:GetParameter",
        "ssm:GetParameters"
      ]
      Resource = "arn:aws:ssm:us-east-1:111111111111:parameter/shared/api/*"
    }]
  })
}

Secrets Manager Cross-Region Replication

resource "aws_secretsmanager_secret" "db_credentials" {
  name = "prod/api/db"
 
  replica {
    region = "us-west-2"
  }
 
  replica {
    region = "eu-west-1"
    kms_key_id = aws_kms_key.eu_west_1.id
  }
}

Decision Matrix

RequirementParameter StoreSecrets Manager
Database credentials with rotation❌ Manual✅ Automatic
API keys (no rotation)✅ Cost-effective⚠️ Works but expensive
Configuration values✅ Designed for this❌ Overkill
Multi-region secrets⚠️ Manual replication✅ Built-in
Budget-conscious✅ $0.15 for 30K calls❌ $0.40/secret/month
Hierarchical organization✅ Path-based❌ Flat namespace
Large secrets (>4KB)⚠️ 8KB max (advanced)✅ 64KB
Compliance (audit trail)✅ CloudTrail✅ CloudTrail + versioning
Cross-account access✅ Resource policies✅ Resource policies

Best Practices

1. Use Least Privilege IAM Policies

# ❌ Bad: Overly permissive
resource "aws_iam_role_policy" "bad_secrets_access" {
  role = aws_iam_role.ecs_execution.id
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "secretsmanager:*"
      Resource = "*"
    }]
  })
}
 
# ✅ Good: Specific resources and actions
resource "aws_iam_role_policy" "good_secrets_access" {
  role = aws_iam_role.ecs_execution.id
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "secretsmanager:GetSecretValue"
      ]
      Resource = [
        "arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:prod/api/*"
      ]
      Condition = {
        StringEquals = {
          "secretsmanager:VersionStage" = "AWSCURRENT"
        }
      }
    }]
  })
}

2. Enable Encryption with Customer-Managed KMS Keys

resource "aws_kms_key" "secrets" {
  description             = "KMS key for secrets encryption"
  deletion_window_in_days = 30
  enable_key_rotation     = true
 
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "Allow ECS Task Execution Role"
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_role.ecs_execution.arn
        }
        Action = [
          "kms:Decrypt",
          "kms:DescribeKey"
        ]
        Resource = "*"
      }
    ]
  })
}
 
resource "aws_secretsmanager_secret" "db_credentials" {
  name       = "prod/api/db"
  kms_key_id = aws_kms_key.secrets.id
}

3. Implement Secret Caching

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import NodeCache from 'node-cache';
 
const secretsManager = new SecretsManagerClient({});
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
 
export async function getCachedSecret(secretId: string): Promise<any> {
  // Check cache first
  const cached = cache.get(secretId);
  if (cached) {
    return cached;
  }
 
  // Fetch from Secrets Manager
  const response = await secretsManager.send(new GetSecretValueCommand({
    SecretId: secretId,
  }));
 
  const secret = JSON.parse(response.SecretString!);
 
  // Cache for 5 minutes
  cache.set(secretId, secret);
 
  return secret;
}

4. Monitor Secret Access

resource "aws_cloudwatch_log_metric_filter" "unauthorized_secret_access" {
  name           = "unauthorized-secret-access"
  log_group_name = "/aws/cloudtrail"
  pattern        = "{ ($.eventName = GetSecretValue || $.eventName = GetParameter) && $.errorCode = AccessDenied }"
 
  metric_transformation {
    name      = "UnauthorizedSecretAccess"
    namespace = "Security/Secrets"
    value     = "1"
  }
}
 
resource "aws_cloudwatch_metric_alarm" "secret_access_denied" {
  alarm_name          = "unauthorized-secret-access-detected"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "UnauthorizedSecretAccess"
  namespace           = "Security/Secrets"
  period              = 300
  statistic           = "Sum"
  threshold           = 0
  alarm_description   = "Alert on unauthorized secret access attempts"
  alarm_actions       = [aws_sns_topic.security_alerts.arn]
}

5. Rotate Secrets Regularly

# Secrets Manager: Built-in rotation
resource "aws_secretsmanager_secret_rotation" "api_key" {
  secret_id           = aws_secretsmanager_secret.api_key.id
  rotation_lambda_arn = aws_lambda_function.rotate_api_key.arn
 
  rotation_rules {
    automatically_after_days = 90
  }
}
 
# Parameter Store: EventBridge-triggered Lambda
resource "aws_cloudwatch_event_rule" "rotate_parameters" {
  name                = "rotate-parameters-quarterly"
  description         = "Trigger parameter rotation every 90 days"
  schedule_expression = "rate(90 days)"
}
 
resource "aws_cloudwatch_event_target" "rotate_parameters" {
  rule      = aws_cloudwatch_event_rule.rotate_parameters.name
  target_id = "RotateParametersLambda"
  arn       = aws_lambda_function.rotate_parameters.arn
}

Conclusion: Choose Based on Use Case

The choice between Parameter Store and Secrets Manager isn't binary—most production architectures benefit from both:

Use Secrets Manager for:

  • Database credentials requiring automatic rotation
  • Secrets needing multi-region replication
  • Compliance-heavy environments requiring audit trails
  • Large secret values (>4KB)

Use Parameter Store for:

  • Application configuration
  • Feature flags
  • API keys that don't rotate
  • Cost-sensitive workloads
  • Hierarchical secret organization

The cost difference is significant at scale ($400/month for 1,000 secrets), but the operational benefits of automatic rotation often justify Secrets Manager for database credentials while using Parameter Store for everything else.

Start with Parameter Store for configuration and non-rotating secrets. Introduce Secrets Manager selectively for credentials requiring rotation. Monitor costs and adjust based on your actual usage patterns.


Action Items:

  1. Audit current secret storage (environment variables, hardcoded values, config files)
  2. Categorize secrets by rotation requirements
  3. Implement Parameter Store for configuration and non-rotating secrets
  4. Migrate database credentials to Secrets Manager with automatic rotation
  5. Update ECS task definitions to use secret injection
  6. Configure IAM policies with least privilege access
  7. Enable CloudTrail monitoring for secret access patterns

Need Help with Your Cloud Infrastructure?

Our experts are here to guide you through your cloud journey

Schedule a Free Consultation