AWS SSM Parameter Store vs Secrets Manager: Stop Committing .env Files (2026)

Published · Updated

Enterprise security guides universally assume you have a Chief Information Security Officer (CISO), an automated compliance auditing system, and a dedicated platform security team to manage your encryption keys. But when you are a solo developer, an independent contractor, or a bootstrapped founder, over-engineered security patterns don’t just slow you down—they create an operational maze that introduces human error.

You do not need an enterprise-grade secrets governance framework. You need a practical, bulletproof workflow that completely eliminates hardcoded credentials, survives a leaked repo, and does not eat up your weekends with configuration maintenance.


The Solo Trap: .env and the .gitignore Illusion

When you are the only person writing code, speed and convenience usually win. It starts innocently: you drop an API key into a local .env file, promise yourself you will clean it up before moving to production, and then accidentally stage and commit the file to your repository.

Local machine
  ├── Commit 1: .env with live DB password  → secret in git history forever
  └── Commit 2: add .env to .gitignore    → file hidden, history unchanged

GitHub → scrapers scan history → compromised credentials

You cannot fix this with another commit. Because Git functions as a directed acyclic graph (DAG) of snapshots, simply adding .env to your .gitignore and pushing a follow-up commit only hides the file from the current directory tree view. The plaintext key remains permanently baked into your historical commit logs. Automated credential-scraping bots monitor public history feeds constantly; if a repository is ever made public or a personal access token is leaked, these bots will parse your entire commit history in milliseconds.

If you ever commit a raw secret, assume it is instantly compromised. Take these steps immediately:

  1. Rotate the credentials at the source provider (e.g., AWS, Stripe, SendGrid).
  2. Scrub the repository history using dedicated tools like git-filter-repo or BFG Repo-Cleaner.
  3. Never rely on a follow-up commit to fix a security leakage. The optimal approach is ensuring production secrets never touch your local disk in plaintext format.

SSM Parameter Store vs. Secrets Manager

AWS provides two native utilities to pull secrets out of your application source code. For a solo developer, choosing between them comes down to a straightforward calculation of monthly financial overhead versus automated feature requirements:

FeatureSSM SecureStringSecrets Manager
Storage CostFree (standard params)$0.40/secret/month
API CallsFree (standard throughput)$0.05 per 10k requests
Auto RotationCustom Lambda onlyNative templates (e.g., RDS)
Best ForAPI keys, flags, app configRDS passwords, rotation compliance

The Solo Default: SSM SecureString

For almost every standard application variable, third-party API token, or configuration flag, SSM Parameter Store is your default choice. By using the SecureString parameter type, AWS encrypts your data at rest using an AWS Key Management Service (KMS) customer-managed key or default AWS managed key. It costs nothing to store up to 10,000 parameters per region, keeping your baseline cloud utility bill at zero.

When to Choose Secrets Manager

You should opt for AWS Secrets Manager primarily when you are using an AWS database engine (such as Amazon RDS) and want the system to handle complex password synchronization completely on autopilot. Secrets Manager charges $0.40 per secret per month. While that sounds negligible, if your application requires 40 distinct configuration keys, using Secrets Manager indiscriminately inflicts an unnecessary $16 per month (~₹1,300 INR) tax on your architecture.

On Kubernetes Note: If you deploy your applications to an EKS or self-hosted Kubernetes cluster, consider utilizing Sealed Secrets. This mechanism lets you safely encrypt your sensitive keys into an encrypted file that can be committed directly to Git; only the controller running inside your live cluster possesses the private key required to decrypt it. See also ConfigMaps & Secrets production patterns.


Minimal IAM: One Role, One Path

Do not fall into the habit of assigning broad AdministratorAccess or wide wildcards (*) to your application’s computing identities. Instead, attach an execution role to your EC2 instance, ECS task, or AWS Lambda function that limits reading access exclusively to your application’s specific configuration path.

Adopt a strict naming hierarchy for your parameters based on paths: /[environment]/[app-name]/[secret-name]

For example, your production backend database string should live at: /production/api/db_password

By mapping your variables to a clean path structure, you can enforce a lean, least-privilege IAM security policy that looks like this:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ssm:GetParameter",
      "ssm:GetParameters",
      "ssm:GetParametersByPath"
    ],
    "Resource": "arn:aws:ssm:ap-south-1:123456789012:parameter/production/my-app/*"
  }]
}

This single policy ensures your application execution container can systematically pull its required environment configurations at boot via ssm:GetParametersByPath while remaining completely sandboxed from any other infrastructure parameters inside your cloud account.


Rotation: What to Automate Solo

Enterprise compliance standards dictate rotating every operational credential every 30 to 90 days. For a single engineer, trying to write and maintain custom AWS Lambda rotation scripts for dozens of distinct third-party SaaS vendors is a massive time sink that introduces breaking synchronization risks.

Prioritize your rotation workloads pragmatically:

  • RDS and Core Infrastructure Passwords: Turn on native AWS Secrets Manager auto-rotation. Because AWS manages both sides of the handshake (the database authentication layer and the storage vault), it runs seamlessly on a set-and-forget cadence with zero maintenance code required from you.
  • Third-Party API Keys (Stripe, SendGrid, Auth0): Keep these on a manual rotation cycle. Trigger a manual rotation event exclusively during major operational updates, when an external vendor experiences a public security breach, or during your annual infrastructure cleanup windows.

CI/CD: Inject at Runtime, Not Build Time

When constructing deployment execution paths within environments like GitHub Actions, keep your application build pipelines completely isolated from your production application secrets.

1. Leverage OpenID Connect (OIDC)

Stop generating long-lived IAM user access keys (AWS_ACCESS_KEY_ID) and storing them as static strings in your GitHub repository settings. Instead, configure an IAM Identity Provider to establish an OIDC trust relationship between GitHub and AWS. This enables GitHub Actions to request short-lived, temporary security tokens that expire automatically after your deployment job concludes.

# GitHub Actions — prefer OIDC over static keys when possible
permissions:
  id-token: write
  contents: read

2. Runtime Injection

Never pack actual production database credentials or payment keys into your Docker container images or compiled application codebases during the CI build phase. Doing so means anyone with read access to your container registry instantly owns your production database.

Instead, compile a clean, stateless application artifact. When the container boots up in your AWS production environment, have your application framework fetch its required runtime configuration keys directly from the AWS SSM Parameter Store pathway.


Hands-on: Bootstrap SSM for a new project

#!/bin/bash
# setup-ssm.sh — create a clean parameter hierarchy and IAM role

APP_NAME="myapp"
ENV="production"
REGION="ap-south-1"

# 1. Create parameter hierarchy in SSM
aws ssm put-parameter \
  --name "/${ENV}/${APP_NAME}/db_password" \
  --value "$(openssl rand -base64 32)" \
  --type SecureString \
  --key-id alias/aws/ssm \
  --region ${REGION}

aws ssm put-parameter \
  --name "/${ENV}/${APP_NAME}/api_key_stripe" \
  --value "sk_live_xxxx" \
  --type SecureString \
  --region ${REGION}

# 2. Create IAM role for EC2/ECS/Lambda
cat > trust-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Service": "ec2.amazonaws.com"
    },
    "Action": "sts:AssumeRole"
  }]
}
EOF

aws iam create-role \
  --role-name ${APP_NAME}-app-role \
  --assume-role-policy-document file://trust-policy.json

# 3. Attach least-privilege policy
cat > ssm-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ssm:GetParameter",
      "ssm:GetParameters",
      "ssm:GetParametersByPath"
    ],
    "Resource": "arn:aws:ssm:${REGION}:$(aws sts get-caller-identity --query Account --output text):parameter/${ENV}/${APP_NAME}/*"
  }, {
    "Effect": "Allow",
    "Action": "kms:Decrypt",
    "Resource": "arn:aws:kms:${REGION}:$(aws sts get-caller-identity --query Account --output text):key/*",
    "Condition": {
      "StringEquals": {
        "kms:ViaService": "ssm.${REGION}.amazonaws.com"
      }
    }
  }]
}
EOF

aws iam put-role-policy \
  --role-name ${APP_NAME}-app-role \
  --policy-name SSMParameterAccess \
  --policy-document file://ssm-policy.json

echo "✓ SSM hierarchy and IAM role created"
aws ssm get-parameters-by-path \
  --path "/${ENV}/${APP_NAME}" \
  --recursive \
  --region ${REGION}

Fetch secrets at app startup (Node.js example)

// src/config.ts — load secrets once at startup, not per request
import AWS from 'aws-sdk';

const ssm = new AWS.SSM({ region: 'ap-south-1' });

async function loadSecrets(env: string, appName: string) {
  try {
    const response = await ssm.getParametersByPath({
      Path: `/${env}/${appName}`,
      Recursive: true,
      WithDecryption: true,
    }).promise();

    const config: Record<string, string> = {};
    response.Parameters?.forEach((param) => {
      const key = param.Name?.split('/').pop() || '';
      config[key] = param.Value || '';
    });

    return config;
  } catch (err) {
    console.error('Failed to load secrets:', err);
    throw new Error('SSM initialization failed');
  }
}

export const appConfig = await loadSecrets(
  process.env.NODE_ENV || 'staging',
  'myapp'
);

// Use in your app
export const DB_PASSWORD = appConfig.db_password;
export const STRIPE_KEY = appConfig.api_key_stripe;

Important: Load secrets once at startup, not inside request handlers. Each SSM call costs ~$0.05 per 10k requests; re-fetching per request kills free tier instantly.


Python (with caching layer)

# config.py — load and cache secrets
import boto3
import os
from functools import lru_cache

ssm_client = boto3.client('ssm', region_name='ap-south-1')

@lru_cache(maxsize=1)
def load_secrets(env: str, app_name: str) -> dict:
    """Load secrets from SSM and cache for app lifetime"""
    try:
        response = ssm_client.get_parameters_by_path(
            Path=f'/{env}/{app_name}',
            Recursive=True,
            WithDecryption=True,
        )
        config = {}
        for param in response['Parameters']:
            key = param['Name'].split('/')[-1]
            config[key] = param['Value']
        return config
    except Exception as e:
        print(f'SSM load failed: {e}')
        raise

config = load_secrets(os.getenv('ENV', 'staging'), 'myapp')
DB_PASSWORD = config['db_password']
STRIPE_KEY = config['api_key_stripe']

GitHub Actions: OIDC + runtime injection

Storing static AWS_ACCESS_KEY_ID in GitHub Secrets is convenient but risky — the key lives forever. Use OIDC for short-lived tokens instead:

1. Set up AWS side (IAM Identity Provider):

# Create OIDC identity provider
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
  --client-id-list sts.amazonaws.com

# Create role for GitHub Actions
cat > github-oidc-trust.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
      },
      "StringLike": {
        "token.actions.githubusercontent.com:sub": "repo:YOUR_ORG/YOUR_REPO:*"
      }
    }
  }]
}
EOF

aws iam create-role \
  --role-name github-actions-deploy \
  --assume-role-policy-document file://github-oidc-trust.json

2. Attach the app deployment policy:

# Reuse the SSM policy from earlier
aws iam put-role-policy \
  --role-name github-actions-deploy \
  --policy-name AppSecrets \
  --policy-document file://ssm-policy.json

3. GitHub Actions workflow (no static keys stored):

name: Deploy with OIDC

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-deploy
          aws-region: ap-south-1

      - name: Pull app secrets and deploy
        run: |
          # App fetches from SSM at startup (see Node/Python examples)
          npm run build
          npm run deploy

Advantages:

  • No static IAM keys in GitHub
  • Token expires after job (default 1 hour, set ACTIONS_ID_TOKEN_REQUEST_TOKEN_EXPIRATION_MINUTES if needed)
  • Revoke with a single IAM role update, affects all future deployments
  • Audit trail: CloudTrail logs which GitHub Actions job assumed the role

Emergency: You committed a secret

Timeline: Assume the credential is live on GitHub within 5 minutes.

  1. Rotate immediately (don’t debug, just rotate):

    # AWS — rotate access keys
    aws iam delete-access-key --access-key-id AKIAIOSFODNN7EXAMPLE
    # RDS password
    aws rds modify-db-instance --db-instance-identifier mydb \
      --master-user-password "$(openssl rand -base64 32)" --apply-immediately
  2. Scrub history (pick one):

    # Option A: git-filter-repo (recommended)
    git filter-repo --invert-paths --path .env
    
    # Option B: BFG (faster for large repos)
    bfg --delete-files .env
    
    # After rewriting, force-push (nuclear option)
    git push origin --force-with-lease --all
  3. Force-push cleanup to GitHub (breaks clones of the old history):

    git push origin --force-with-lease main
    # Notify team: "History rewritten, please git clone fresh"
  4. Check GitHub’s secret scanning — go to Settings → Security → Secret scanning and verify GitHub flagged the exposure. If using push protection, it should have blocked the commit.


New Project Checklist

Before you write your first line of functional application logic, run through this baseline checklist to secure your codebase from day zero:

  • Establish the Git Guard: Verify your .gitignore file explicitly blocks .env, *.pem, *.key, *.json (esp. service accounts), and local .aws/ directories.

    .env
    .env.local
    .env.*.local
    *.pem
    *.key
    credentials.json
    .aws/credentials
    .aws/config
  • Define the Path Prefix: Map out your configuration environment paths clearly:

    /staging/myapp/db_password
    /production/myapp/db_password
    /production/myapp/api_key_stripe
  • Decouple Local Development: Use dotenv files locally (never committed), mocking libraries like LocalStack for AWS, or Docker Compose with service containers. Ensure no production fallback strings in code:

    // ❌ BAD
    const dbPassword = process.env.DB_PASSWORD || 'prod-secret-hardcoded';
    
    // ✅ GOOD
    const dbPassword = process.env.DB_PASSWORD;
    if (!dbPassword) throw new Error('DB_PASSWORD not set — local dev needs .env');
  • Enable Repository Protection: GitHub Settings → Code security & analysis:

    • Enable secret scanning (free, flags exposed secrets in issues)
    • Enable push protection (blocks commits with detected secrets locally)
    • Set branch protection to require reviews before merge
  • Set up OIDC for CI/CD (GitHub Actions, GitLab CI, etc.) instead of static API keys.

  • Document the rotation schedule: Who rotates what, and when?

    ## Secrets Rotation Schedule
    - RDS passwords: Auto-rotated via Secrets Manager (30 days)
    - Stripe API keys: Manual rotation on vendor breaches or offboarding
    - GitHub PAT: Rotate before employee departure