Secrets Management Best Practices: Securing Your Applications in 2026

Secrets Management Best Practices: Securing Your Applications in 2026

By Pashalis Laoutaris Category: Secrets management 28 min read

Secrets Management Best Practices: Securing Your Applications in 2026


Table of Contents

  1. Introduction
  2. What Are “Secrets”?
  3. The Risks of Poor Secrets Management
  4. Core Best Practices
  5. Integrating Secrets Management into CI/CD
  6. Beyond Secrets: Advanced Architectural Patterns
  7. Top Tools: Beginner to Advanced
  8. Conclusion

Introduction

The 2025 Verizon Data Breach Investigations Report found that compromised credentials are involved in over 77% of web application breaches. Not misconfigured servers, not zero-days — stolen or leaked passwords and API keys. And in most cases, these credentials weren’t stolen through sophisticated hacking. They were simply… left in the open.

A developer pushed a commit with an AWS key embedded in the code. A .env file was shared over Slack. A database password lived unchanged in a config file for four years. Sounds familiar?

This is the secrets management problem, and if you’re building or operating any modern application, it affects you.

This post is your definitive guide. Whether you’re a solo developer just learning to avoid git commit-ing your API keys, or a senior DevOps engineer architecting a zero-trust secrets infrastructure for a Fortune 500 company, you’ll find actionable advice here.


What Are “Secrets”?

In software, a “secret” is any sensitive credential used for machine-to-machine authentication or encryption. These are not the same as human passwords (though those matter too). We’re talking about:

Secret TypeExamples
API KeysStripe keys, OpenAI keys, SendGrid keys
Database CredentialsPostgreSQL passwords, MongoDB URIs
OAuth TokensService account tokens, refresh tokens
SSH KeysDeploy keys, server access keys
TLS/SSL CertificatesPrivate keys for HTTPS
Encryption KeysAES keys, RSA private keys
Cloud IAM CredentialsAWS Access Key ID + Secret, GCP service account JSON
Webhook Signing SecretsGitHub webhook secrets, Stripe webhook secrets

The common thread: if this value is compromised, an attacker gains access to systems, data, or the ability to impersonate your application.

The Problem: Secret Sprawl

As microservices, multi-cloud, and DevOps practices have evolved, secrets have multiplied and scattered across your infrastructure:

 Secret Sprawl Where Secrets End Up (By Accident)

├── source_code/
   ├── config.py API key hardcoded in line 42
   ├── docker-compose.yml DB password in environment block
   └── .env Committed to Git "just this once"

├── CI/CD Pipelines/
   ├── GitHub Actions Secret in plaintext in YAML workflow
   └── Jenkins Password in build script

├── Developer Machines/
   ├── ~/.bash_profile export AWS_SECRET="abc123"
   └── Slack DMs "hey here's the prod DB password"

└── Random Wikis/
    └── Confluence "Prod credentials" page (view-only? maybe)

This is secret sprawl: uncontrolled, untracked, unaudited secrets living everywhere and nowhere. It’s the root cause of most credential-related breaches.


The Risks of Poor Secrets Management

Before diving into solutions, let’s understand what’s at stake.

Hardcoding Secrets in Source Code

The most common mistake in the industry. A developer is rushing to ship a feature, needs an API key to test, and drops it directly in the code. It ships. It gets forgotten.

# ❌ DON'T EVER DO THIS
import stripe

stripe.api_key = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"  # Real production key!

def charge_customer(amount, token):
    stripe.Charge.create(amount=amount, currency="usd", source=token)

Even if this isn’t pushed to a public repository, it’s still a serious risk:

  • Every developer with repo access can see it
  • It appears in git log forever, even if later removed
  • It travels into backups, forks, and CI/CD build logs
  • Tools like GitHub’s secret scanning will flag it — but after the fact

Real-world consequence: In 2023, Toyota exposed a credential in a public GitHub repo for nearly five years, potentially exposing the data of 296,000 customers. The key was sitting there the whole time.

Lack of Visibility and Auditing

If you don’t know where your secrets are, you cannot answer these questions:

  • Which services are using this database password?
  • Who accessed the production API key last Tuesday at 2 AM?
  • When this key was rotated last week, did the rotation break anything?
  • Is this secret still in use, or can we safely delete it?

Without a centralized system, secrets management is flying blind. And blind spots are exactly where attackers thrive.

Static and Long-Lived Credentials

Many organizations use the same database password for years. This is catastrophically risky:

  • Wide blast radius: If it’s ever leaked, the attacker has unrestricted access for as long as the credential is valid
  • Rotation paralysis: The longer a credential lives, the more systems depend on it, making rotation feel impossible
  • No breach detection window: You can’t distinguish a legitimate connection from a malicious one using the same long-lived key

Think about it like a physical key to your office. You’d never leave the same key unchanged for five years without knowing exactly who has a copy of it.

The “Post-it Note” Equivalent for DevOps

Secret Management Pipeline

The #ops Slack channel. The email thread titled “FWD: Prod DB Creds.” The shared Google Doc. The Confluence page anyone can view.

Sharing secrets through unencrypted communication channels is the digital equivalent of taping your PIN to your debit card. Once a secret leaves a secure system and enters a communication channel, you have zero control over where it ends up, who forwards it, or how long it persists in chat history.

The AI Boom: Jupyter Notebooks and LLM API Keys

One of the fastest-growing secret leak vectors in 2025 and 2026 is AI development. With the explosion of LLM-powered applications, data scientists and developers routinely hardcode OPENAI_API_KEY, ANTHROPIC_API_KEY, or HuggingFace tokens directly into Jupyter Notebooks, Python scripts, and LangChain/LlamaIndex setups.

# ❌ Extremely common in data science notebooks — and extremely dangerous
import openai

openai.api_key = "sk-proj-abc123..."   # ❌ Hardcoded LLM key

response = openai.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Summarize this document..."}]
)

This is compounded by a critical Jupyter Notebook trap: notebooks store cell outputs and variable state in their underlying JSON structure. Even if you delete a code cell containing a key, the key may still be embedded in the notebook’s output history — and will be committed to Git along with the .ipynb file.

# A Jupyter notebook is just JSON — secrets hide in plain sight
$ cat my_analysis.ipynb | python3 -m json.tool | grep -i "api_key"
# "OPENAI_API_KEY": "sk-proj-abc123..."   ← still there after cell deletion!

How to fix it for AI/data science workflows:

# ✅ Option 1: Use environment variables (minimum viable fix)
import os
import openai

openai.api_key = os.environ["OPENAI_API_KEY"]

# ✅ Option 2: Use python-dotenv (common in notebooks — still use .gitignore!)
from dotenv import load_dotenv
load_dotenv()  # Reads from .env file
openai.api_key = os.getenv("OPENAI_API_KEY")

# ✅ Option 3: Use a secrets manager (best for production AI apps)
import boto3, json
secret = json.loads(
    boto3.client("secretsmanager").get_secret_value(SecretId="prod/ai/openai")["SecretString"]
)
openai.api_key = secret["api_key"]
# Add to .gitignore — strip notebook outputs before committing
pip install nbstripout
nbstripout --install   # Installs a git filter that auto-strips outputs on commit

LLM API keys should be treated with the exact same rigor as production database passwords. A leaked OpenAI key can rack up thousands of dollars in charges within hours. A leaked key for an internal LLM with access to proprietary data can be far worse.


Core Best Practices

Never Hardcode Secrets (The Golden Rule)

This is non-negotiable. No exceptions. Not for “just testing.” Not for “it’s a private repo.” Not for “I’ll fix it later.”

❌ Wrong — hardcoded in source:

// config.js
const dbConfig = {
  host: "prod-db.company.com",
  user: "admin",
  password: "SuperSecret123!", // ❌ Hardcoded
  database: "customers",
};

✅ Right — injected via environment variable:

// config.js
const dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD, // ✅ Read from environment
  database: process.env.DB_NAME,
};

✅ Even better — fetched from a secrets manager at runtime:

// config.js (Node.js + AWS Secrets Manager)
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";

const client = new SecretsManagerClient({ region: "us-east-1" });

async function getDbConfig() {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: "prod/myapp/database" }),
  );
  return JSON.parse(response.SecretString);
}

Add a .gitignore to protect local secret files:

# .gitignore

# Environment variable files
.env
.env.local
.env.production
.env.*.local

# Key and certificate files
*.key
*.pem
secrets.json

# Package manager auth configs (contain publishing tokens!)
.npmrc
.pypirc
pip.conf

# Cloud credential files
.aws/credentials
gcloud/application_default_credentials.json
*.tfvars          # Terraform variable files often contain secrets

# Jupyter Notebooks — strip outputs, but also ignore local checkpoint files
.ipynb_checkpoints/

# macOS metadata (can sometimes capture env vars from terminal sessions)
.DS_Store

Add a .env.example for documentation (no real values):

# .env.example — commit this, NOT .env
DB_HOST=your-database-host
DB_USER=your-database-user
DB_PASSWORD=your-database-password
DB_NAME=your-database-name
STRIPE_SECRET_KEY=sk_live_...

Centralize Your Secrets

A dedicated secrets manager is the “single source of truth” for all credentials. Instead of secrets scattered across config files, Slack messages, and developer machines, every application and service fetches secrets from one place.

Architecture: Before vs. After

❌ Before (Secret Sprawl)                ✅ After (Centralized)
────────────────────────                 ─────────────────────────
App A ──── .env file                     App A ──┐
App B ──── config.yaml                   App B ──┤
App C ──── Kubernetes secret             App C ──┤──► Secrets Manager ◄── Audit Logs
CI/CD ──── Pipeline env var             CI/CD ──┤              │
Dev 1 ──── ~/.bash_profile              Dev 1 ──┘              └──► Encryption
Dev 2 ──── Slack message                                              (AES-256)

Example: Fetching a secret from HashiCorp Vault

# Authenticate and fetch a secret via CLI
vault login -method=aws

vault kv get -mount=secret prod/myapp/database
# Output:
# == Secret Path ==
# secret/data/prod/myapp/database
#
# ======= Data =======
# Key        Value
# ---        -----
# password   correcthorsebatterystaple
# username   app_user

Example: Fetching via Vault’s HTTP API (Python)

import hvac

client = hvac.Client(url='https://vault.company.com:8200', token=os.environ['VAULT_TOKEN'])

secret = client.secrets.kv.v2.read_secret_version(
    path='prod/myapp/database',
    mount_point='secret'
)

db_password = secret['data']['data']['password']

Example: Using AWS Secrets Manager (Python)

import boto3
import json

def get_secret(secret_name: str, region: str = "us-east-1") -> dict:
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

# Usage
creds = get_secret("prod/myapp/database")
print(creds["username"])  # app_user
print(creds["password"])  # fetched securely, never hardcoded

Enforce Least Privilege and RBAC

Secret Management Pipeline

Every application, user, and service should have access to only the secrets they absolutely need — and nothing more. This is the Principle of Least Privilege (PoLP).

Pair this with Role-Based Access Control (RBAC) to segment access by team and environment.

Vault Policy Example (HCL)

# policy: backend-app-prod.hcl
# Only allows the backend app to read its own production secrets

path "secret/data/prod/backend/*" {
  capabilities = ["read"]
}

# Explicitly deny access to other environments
path "secret/data/staging/*" {
  capabilities = ["deny"]
}

path "secret/data/prod/payments/*" {
  capabilities = ["deny"]   # Payments service manages its own secrets
}

AWS IAM Policy for Scoped Secrets Access

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowBackendAppSecrets",
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/backend-app/*"
    },
    {
      "Sid": "DenyEverythingElse",
      "Effect": "Deny",
      "Action": "secretsmanager:*",
      "NotResource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/backend-app/*"
    }
  ]
}

RBAC Matrix Example

RoleDev SecretsStaging SecretsProd SecretsRotate Secrets
Developer✅ Read/Write✅ Read
DevOps Engineer✅ Read
CI/CD Pipeline✅ Read✅ Read
Security Team
Application (Runtime)✅ Own secrets only

Automate Secret Rotation

Manual secret rotation is a lie you tell yourself. In practice, manual rotation:

  • Gets postponed indefinitely (“we’ll do it next sprint”)
  • Is error-prone (miss one service, cause an outage)
  • Causes downtime if not coordinated properly
  • Simply doesn’t happen often enough

The goal: rotate secrets automatically, frequently, and without downtime.

AWS Secrets Manager — Automated Rotation (Terraform)

resource "aws_secretsmanager_secret_rotation" "db_rotation" {
  secret_id           = aws_secretsmanager_secret.db_password.id
  rotation_lambda_arn = aws_lambda_function.rotate_db_secret.arn

  rotation_rules {
    automatically_after_days = 30  # Rotate every 30 days automatically
  }
}

Vault — Dynamic Secrets with TTL (a preview of practice #5)

# Configure PostgreSQL secrets engine to auto-rotate
vault secrets enable database

vault write database/config/my-postgresql-database \
    plugin_name=postgresql-database-plugin \
    allowed_roles="*" \
    connection_url="postgresql://{{username}}:{{password}}@db.company.com:5432/mydb" \
    username="vault_admin" \
    password="initial-password"

vault write database/roles/my-role \
    db_name=my-postgresql-database \
    creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';" \
    default_ttl="1h" \   # Credentials expire after 1 hour
    max_ttl="24h"

A Zero-Downtime Rotation Strategy:

1. Generate new credential in secrets manager
2. Update the credential in the target system (e.g., database allows BOTH old + new)
3. Deploy applications with the new credential
4. Verify all applications are using the new credential
5. Revoke the old credential
6. Log the rotation event for audit

⚠️ The Terraform State File Trap

If you use Infrastructure as Code (Terraform, Pulumi, etc.) to provision or reference secrets, be aware that Terraform stores resource attributes — including secret values — in plaintext in the .tfstate file by default. This means any secret you declare or look up in Terraform may end up readable in your state file.

# This is safe to write...
data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "prod/myapp/database"
}

# ...but the resolved value will appear in terraform.tfstate in plaintext!
# "secret_string": "{ \"password\": \"SuperSecret123!\" }"

Mitigations:

  • Always use a remote, encrypted state backend (e.g., S3 + KMS, Terraform Cloud, or Atlantis) — never commit terraform.tfstate to Git
  • Restrict access to your state backend as strictly as you restrict production secrets
  • Consider using sensitive = true on output values to suppress terminal display
  • Prefer fetching secrets at application runtime rather than during Terraform apply
# Mark outputs as sensitive to suppress terminal display
output "db_password" {
  value     = aws_secretsmanager_secret_version.db.secret_string
  sensitive = true  # Won't print to console, but still stored in state!
}

# Backend config — encrypt state at rest with KMS
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    kms_key_id     = "alias/terraform-state-key"  # AES-256 encryption
    dynamodb_table = "terraform-locks"
  }
}

Move Towards Dynamic / Ephemeral Secrets

Dynamic secrets are the gold standard. Instead of a static password that lives forever, your application requests a credential that:

  • Is generated on-demand, uniquely for that request
  • Expires automatically after a short TTL (e.g., 15 minutes or 1 hour)
  • Is automatically revoked when no longer needed

How Dynamic Secrets Work

Application                HashiCorp Vault             Database
    │                            │                         │
    │─── "I need DB access" ────►│                         │
    │    (authenticated request)  │                         │
    │                            │──── CREATE ROLE ───────►│
    │                            │     username: v-app-abc  │
    │                            │     password: xK9!mPq2   │
    │                            │     TTL: 60 minutes      │
    │◄── Returns temp credential─│◄─── OK ─────────────────│
    │                            │                         │
    │──────────────────── Connects to DB using temp creds ►│
    │                            │                         │
    │                    [60 minutes later]                │
    │                            │──── DROP ROLE ─────────►│
    │                            │     username: v-app-abc  │
    │                            │◄─── OK ─────────────────│
    │                            │  (credential auto-expired)

Requesting a dynamic DB credential from Vault (Python)

import hvac
import psycopg2

# Authenticate to Vault (using AWS IAM auth in production)
client = hvac.Client(url='https://vault.company.com:8200')
client.auth.aws.iam_login(role='my-app-role')

# Request a dynamic, short-lived database credential
db_creds = client.secrets.database.generate_credentials(name='my-role')
username = db_creds['data']['username']  # e.g., v-app-1684932847-abc123
password = db_creds['data']['password']  # e.g., A-xK9!mPq2-xyz789
lease_id = db_creds['lease_id']

# Connect with the ephemeral credential
conn = psycopg2.connect(
    host="prod-db.company.com",
    user=username,
    password=password,
    dbname="customers"
)

# ... do database work ...

# Explicitly revoke credential when done (optional — it expires anyway)
client.sys.revoke_lease(lease_id=lease_id)

Why this is transformative:

  • Stolen credential? Useless — it expired in an hour
  • Breach investigation? Every credential is unique per request — full attribution
  • No rotation policy needed — credentials literally cannot become stale

Encrypt Data at Rest and in Transit

A secrets manager is only as secure as its encryption. Ensure:

  • At rest: All stored secrets use AES-256 encryption (industry standard)
  • In transit: All API calls to fetch secrets use TLS 1.2+ (enforce HTTPS)
  • Key management: The encryption keys themselves are protected by Hardware Security Modules (HSMs) where possible

Verifying TLS is enforced in Vault (HCL config)

# vault.hcl — server configuration
listener "tcp" {
  address       = "0.0.0.0:8200"
  tls_cert_file = "/etc/vault/tls/vault.crt"
  tls_key_file  = "/etc/vault/tls/vault.key"
  tls_min_version = "tls12"           # Enforce TLS 1.2 minimum
  tls_cipher_suites = "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
}

seal "awskms" {
  region     = "us-east-1"
  kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123"  # HSM-backed key
}

Enforcing HTTPS for API secret fetches (Python example with cert verification)

import requests

# GOOD: Certificate verification enabled by default
response = requests.get(
    "https://vault.company.com:8200/v1/secret/data/myapp",
    headers={"X-Vault-Token": os.environ["VAULT_TOKEN"]},
    verify=True,    # ✅ Always verify TLS certificate (default: True)
    timeout=5
)

# BAD: Never do this in production
# response = requests.get(..., verify=False)  # ❌ Disables TLS verification

Kubernetes: Encrypting etcd Secrets at Rest

# encryption-config.yaml — for Kubernetes control plane
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <base64-encoded-32-byte-key> # AES-256
      - identity: {} # Fallback for unencrypted secrets (migrate away from this)

Implement Continuous Auditing and Alerting

You cannot respond to a breach you don’t know about. Every interaction with a secret should be logged, and anomalies should trigger alerts.

What to log for every secret access event

{
  "timestamp": "2026-06-07T14:32:01Z",
  "event_type": "secret_read",
  "secret_path": "secret/data/prod/backend/database",
  "actor": {
    "type": "service_account",
    "identity": "backend-app-prod",
    "aws_account": "123456789012",
    "assumed_role": "arn:aws:iam::123456789012:role/backend-app-role"
  },
  "source_ip": "10.0.1.45",
  "success": true,
  "lease_id": "secret/data/prod/backend/database/abc123xyz"
}

Setting up a Vault audit log

# Enable file-based audit logging in Vault
vault audit enable file file_path=/var/log/vault/audit.log

# Enable syslog output (for SIEM integration)
vault audit enable syslog tag="vault" facility="AUTH"

CloudWatch Alert for Anomalous Secret Access (Terraform)

resource "aws_cloudwatch_metric_alarm" "unusual_secret_access" {
  alarm_name          = "UnusualSecretsManagerAccess"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "1"
  metric_name         = "ResourceAccessDenied"
  namespace           = "CloudTrail"
  period              = "300"   # Check every 5 minutes
  statistic           = "Sum"
  threshold           = "5"     # Alert if >5 denied access attempts in 5 min

  alarm_actions = [aws_sns_topic.security_alerts.arn]

  dimensions = {
    EventSource = "secretsmanager.amazonaws.com"
  }
}

Alerts to configure:

  • Secret accessed from an IP not in your allowlist
  • Application accessing a secret it has never touched before
  • More than N secret reads in a short window (potential exfiltration)
  • Failed authentication attempts to your secrets manager
  • Secret accessed outside of business hours (for sensitive secrets)

Shift Left: Integrate Secret Scanning

The best time to catch a hardcoded secret is before it’s committed. The second best time is before it’s merged. Never after it’s deployed.

GitLeaks is currently the industry-favorite pre-commit secret scanner: actively maintained, fast, zero dependencies, and works across any language or framework.

# Install GitLeaks
brew install gitleaks          # macOS
# or
curl -sSL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz | tar xz
sudo mv gitleaks /usr/local/bin/

# Install pre-commit framework
pip install pre-commit
# .pre-commit-config.yaml — GitLeaks as primary scanner
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.2 # Pin to a specific release
    hooks:
      - id: gitleaks
        name: Detect hardcoded secrets
        description: Detect secrets with GitLeaks
        entry: gitleaks protect --staged --redact -v
        language: golang
        pass_filenames: false
# Install the hooks into your local repo
pre-commit install

# Test it manually
pre-commit run --all-files

Note on detect-secrets: Yelp’s detect-secrets is a solid baseline-based scanner still worth knowing. However, its upstream maintenance has been inconsistent. If you prefer a baseline-based workflow (where you explicitly acknowledge known false positives rather than allowlisting by regex), it remains a valid option — IBM maintains an actively updated fork. For most teams, GitLeaks is the simpler, faster default.

GitHub Actions: TruffleHog Secret Scanning on Every PR

# .github/workflows/secret-scan.yml
name: Secret Scanning

on:
  pull_request:
    branches: [main, staging]
  push:
    branches: [main]

jobs:
  trufflehog:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Full history for thorough scanning

      - name: TruffleHog Scan
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD
          extra_args: --debug --only-verified # Only flag verified live secrets

GitLeaks configuration (alternative scanner)

# .gitleaks.toml
[extend]
  useDefault = true    # Include GitLeaks' built-in ruleset

[[rules]]
  id = "custom-internal-api-key"
  description = "Internal API Key"
  regex = '''INTERNAL-[A-Z0-9]{32}'''
  tags = ["key", "internal"]

[allowlist]
  description = "Global allowlist"
  paths = [
    '''.secrets.baseline''',
    '''tests/fixtures/''',    # Allow test fixtures with dummy values
  ]
  regexes = [
    '''EXAMPLE_KEY_DO_NOT_USE''',   # Known placeholder patterns
  ]

Integrating Secrets Management into CI/CD

Your CI/CD pipeline is one of the highest-value targets for attackers. Build servers hold credentials to deploy to production, push Docker images, and access cloud accounts. They are the keys to the kingdom.

Common CI/CD Secret Anti-Patterns

# ❌ BAD: Secret echoed in build log
- name: Deploy
  run: |
    echo "Deploying with key: ${{ secrets.AWS_SECRET_KEY }}"   # Now visible in logs!
    aws deploy ...

# ❌ BAD: Secret in workflow file
env:
  DATABASE_URL: "postgres://admin:password123@prod-db:5432/myapp"  # Committed to repo!

Best Practices for Pipelines

Use OIDC Instead of Long-Lived Cloud Credentials

OIDC (OpenID Connect) lets your CI/CD pipeline authenticate to cloud providers without storing any credentials at all. The pipeline proves its identity via a short-lived JWT token.

# .github/workflows/deploy.yml
name: Deploy to AWS

permissions:
  id-token: write # Required for OIDC
  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
          aws-region: us-east-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed!

      - name: Deploy
        run: aws s3 sync ./dist s3://my-production-bucket/

Inject Secrets from Vault at Runtime

# .github/workflows/test.yml — fetching secrets from Vault
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Import secrets from HashiCorp Vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.company.com:8200
          method: jwt
          role: github-actions
          secrets: |
            secret/data/prod/database password | DB_PASSWORD ;
            secret/data/prod/stripe secret_key | STRIPE_SECRET_KEY ;

      - name: Run tests
        run: pytest
        env:
          DB_PASSWORD: ${{ env.DB_PASSWORD }} # Injected from Vault
          STRIPE_SECRET_KEY: ${{ env.STRIPE_SECRET_KEY }}

Masking Secrets in GitLab CI

# .gitlab-ci.yml
deploy:
  stage: deploy
  script:
    - echo "Deploying to production"
    # GitLab automatically masks variables marked as "masked" in CI/CD settings
    - ./deploy.sh # $DEPLOY_TOKEN is available but will never appear in logs
  environment:
    name: production

Kubernetes: External Secrets Operator

Rather than storing secrets in Kubernetes etcd (even encrypted), pull them dynamically from your secrets manager:

# external-secret.yaml — syncs AWS Secrets Manager → Kubernetes Secret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: backend-app-secret
  namespace: production
spec:
  refreshInterval: 1h # Sync every hour
  secretStoreRef:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  target:
    name: backend-app-secret # Creates this Kubernetes Secret
    creationPolicy: Owner
  data:
    - secretKey: db-password # Key in the K8s secret
      remoteRef:
        key: prod/backend-app/database # Path in AWS Secrets Manager
        property: password

Beyond Secrets: Advanced Architectural Patterns

Once you’ve mastered the core best practices, the next frontier is architecting systems that reduce your dependence on secrets altogether.

Workload Identity with SPIFFE/SPIRE

OIDC solves the “no long-lived credentials in CI/CD” problem. For service-to-service communication inside your infrastructure — microservice A calling microservice B — the equivalent solution is Workload Identity.

SPIFFE (Secure Production Identity Framework for Everyone) and its reference implementation SPIRE allow every workload (container, VM, Lambda function) to receive a cryptographically verifiable identity at runtime. Services authenticate to each other using mTLS with short-lived X.509 certificates — no shared passwords, no API keys, no secrets to rotate.

Without Workload Identity (secret-based):           With SPIFFE/SPIRE (secretless):
───────────────────────────────────────             ──────────────────────────────────────
Service A ──── "password=abc123" ───► Service B     Service A ──── mTLS (SVID cert) ──► Service B
               ↑ Must be stored,                                   ↑ Auto-issued by SPIRE,
                 rotated, protected                                  expires in minutes,
                                                                    cryptographically verified
# SPIRE Agent config — issues SVIDs to local workloads
agent:
  trust_domain: "example.org"
  server_address: "spire-server"
  server_port: 8081

plugins:
  WorkloadAttestor:
    - k8s:
        skip_kubelet_verification: false

# Each workload gets a SPIFFE Verifiable Identity Document (SVID):
# spiffe://example.org/ns/production/sa/payment-service

This is the secretless architecture end-state: services prove who they are through cryptographic identity rather than shared credentials. Service meshes like Istio and Linkerd implement this pattern by default.

Scoped and Restricted API Tokens

When static API keys are unavoidable (third-party integrations, webhook endpoints), ensure they are as restricted as technically possible:

❌ Global Admin Key                    ✅ Scoped, Restricted Key
─────────────────────────────────      ─────────────────────────────────────────
Permissions: ALL                       Permissions: read:inventory ONLY
IP restriction: None                   IP restriction: 10.0.0.0/8 (internal only)
Expiry: Never                          Expiry: 90 days, auto-rotated
Rate limit: None                       Rate limit: 1000 req/day
Environment: All                       Environment: Production only

Many modern platforms support fine-grained tokens:

# GitHub: Create a fine-grained personal access token
# (Scoped to specific repos, specific permissions, expiry date)
# → Settings > Developer settings > Fine-grained personal access tokens

# Stripe: Use restricted API keys
# → Dashboard > Developers > API keys > Create restricted key
# Only grant the permissions your integration actually needs:
# - charges: read
# - customers: read/write
# - refunds: write
# (NOT "Full access")

# AWS: Use temporary STS credentials with a session policy
aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/limited-deploy-role \
  --role-session-name my-session \
  --duration-seconds 900 \  # 15-minute credential, not permanent
  --policy '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":"arn:aws:s3:::my-bucket/*"}]}'

Top Tools: Beginner to Advanced

Beginner-Friendly Tools

These tools are designed to get you out of .env files and into proper secrets management with minimal friction.

Doppler

  • Best for: Startups, small teams, developer experience
  • What it does: A secrets platform with a CLI that syncs secrets to your local environment, Docker, CI/CD, and cloud platforms
  • Difficulty: ⭐ Easy
# Install Doppler CLI
brew install dopplerhq/cli/doppler

# Login and setup
doppler login
doppler setup  # Select your project and config

# Run your app with secrets injected automatically
doppler run -- node server.js

# Or export as environment variables
doppler secrets download --no-file --format=env > .env.local

Infisical

  • Best for: Open-source alternative to Doppler; teams wanting self-hosting
  • What it does: End-to-end encrypted secrets management with a great UI, CLI, and SDK support
  • Difficulty: ⭐ Easy
# Install CLI
npm install -g @infisical/cli

# Login
infisical login

# Inject secrets when running your app
infisical run --env=prod -- python app.py

# Fetch specific secrets in your code (Python SDK)
from infisical_sdk import InfisicalSDKClient

client = InfisicalSDKClient(host="https://app.infisical.com")
client.auth.universal_auth.login(
    client_id="YOUR_CLIENT_ID",
    client_secret="YOUR_CLIENT_SECRET"
)

secret = client.secrets.get_secret_by_name(
    secret_name="DB_PASSWORD",
    project_id="YOUR_PROJECT_ID",
    environment_slug="prod",
    secret_path="/"
)
print(secret.secretValue)

1Password Secrets Automation

  • Best for: Teams already using 1Password for password management
  • What it does: Extends 1Password into developer workflows with a CLI and SDK
  • Difficulty: ⭐⭐ Easy-Medium
# Use 1Password CLI to inject secrets
op run --env-file=".env.template" -- node server.js

# .env.template references 1Password items
DB_PASSWORD=op://Production/Database/password
STRIPE_KEY=op://Production/Stripe/secret_key

Intermediate Tools

AWS Secrets Manager

  • Best for: Teams already on AWS
  • What it does: Native AWS secrets storage with automatic rotation, fine-grained IAM, and deep integration with RDS, Lambda, ECS, etc.
  • Difficulty: ⭐⭐ Medium
# Python: fetch and cache a secret
import boto3
import json
from functools import lru_cache

@lru_cache(maxsize=None)
def get_secret(secret_name: str) -> dict:
    client = boto3.client("secretsmanager", region_name="us-east-1")
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

Azure Key Vault

  • Best for: Teams on Azure / Microsoft ecosystem
  • What it does: Stores keys, secrets, and certificates; integrates with Azure AD for identity-based access
# Python: Azure Key Vault
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

credential = DefaultAzureCredential()
client = SecretClient(
    vault_url="https://my-key-vault.vault.azure.net/",
    credential=credential
)

secret = client.get_secret("DatabasePassword")
print(secret.value)

Google Cloud Secret Manager

  • Best for: Teams on GCP
  • What it does: Simple, fully-managed secrets service with IAM integration and versioning
# Python: Google Cloud Secret Manager
from google.cloud import secretmanager

client = secretmanager.SecretManagerServiceClient()
name = "projects/my-project/secrets/db-password/versions/latest"

response = client.access_secret_version(request={"name": name})
password = response.payload.data.decode("UTF-8")

Advanced / Enterprise Tools

HashiCorp Vault (Source-Available + Enterprise)

  • Best for: Large teams, multi-cloud, dynamic secrets, zero-trust architecture
  • What it does: The most powerful and flexible secrets platform available. Supports 50+ secret backends, dynamic credentials, PKI, SSH certificate management, and more
  • Difficulty: ⭐⭐⭐⭐ Advanced
  • License note: As of late 2023, HashiCorp switched Vault from an open-source (MPL 2.0) license to the Business Source License (BSL). This means it is source-available but no longer OSI-approved open-source. Teams that specifically require a true open-source license should evaluate OpenBao, a Linux Foundation fork of Vault that maintains an OSI-approved MPL 2.0 license and is API-compatible with Vault.
# Production-grade Vault setup (Docker Compose snippet)
version: '3.8'
services:
  vault:
    image: hashicorp/vault:1.17
    command: server
    volumes:
      - ./vault-config:/vault/config
      - vault-data:/vault/data
    environment:
      VAULT_ADDR: "https://0.0.0.0:8200"
    ports:
      - "8200:8200"
    cap_add:
      - IPC_LOCK   # Prevents vault memory from being swapped to disk

volumes:
  vault-data:
# vault-config/vault.hcl
storage "raft" {
  path    = "/vault/data"
  node_id = "node1"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_cert_file = "/vault/tls/vault.crt"
  tls_key_file  = "/vault/tls/vault.key"
}

api_addr     = "https://vault.company.com:8200"
cluster_addr = "https://vault.company.com:8201"
ui           = true

# Auto-unseal using AWS KMS
seal "awskms" {
  region     = "us-east-1"
  kms_key_id = "alias/vault-unseal-key"
}

CyberArk Conjur

  • Best for: Enterprise compliance-driven environments (banking, healthcare)
  • What it does: Machine identity-based secrets management with deep PAM (Privileged Access Management) capabilities and compliance reporting
  • Difficulty: ⭐⭐⭐⭐⭐ Enterprise

Kubernetes Secrets Store CSI Driver

  • Best for: Kubernetes-native workloads needing secrets from external stores
  • What it does: Mounts secrets from Vault, AWS, Azure, or GCP directly into Kubernetes pods as volumes — no SDK required in application code
# secretproviderclass.yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: app-secrets
spec:
  provider: aws
  parameters:
    objects: |
      - objectName: "prod/myapp/database"
        objectType: "secretsmanager"
        jmesPath:
          - path: "password"
            objectAlias: "db-password"
---
# pod.yaml
spec:
  containers:
    - name: app
      volumeMounts:
        - name: secrets-store
          mountPath: "/mnt/secrets"
          readOnly: true
  volumes:
    - name: secrets-store
      csi:
        driver: secrets-store.csi.k8s.io
        readOnly: true
        volumeAttributes:
          secretProviderClass: "app-secrets"
# Secrets are now available at /mnt/secrets/db-password — no SDK, no env vars!

Secret Scanners

These tools scan your codebase and git history for exposed secrets.

ToolBest ForOpen SourceCI/CD Integration
TruffleHogDeep git history scanning, verified secrets
GitLeaksFast pre-commit and CI scanning
GitGuardianReal-time monitoring, public repo scanningPartially
GitHub Advanced SecurityGitHub-native secret scanning❌ (paid)✅ Built-in
detect-secrets (Yelp)Baseline-based pre-commit scanning
SemgrepCustom rule-based code scanning

TruffleHog: Scan an entire git history

# Install
brew install trufflesecurity/trufflehog/trufflehog

# Scan local repository (full git history)
trufflehog git file://. --since-commit HEAD~50 --only-verified

# Scan a GitHub repository
trufflehog github --org=myorg --only-verified

# Output in JSON for SIEM integration
trufflehog git file://. --json | jq '.SourceMetadata.Data.Git.commit'

GitLeaks: Pre-commit and CI scanning

# Install
brew install gitleaks

# Scan staged changes (pre-commit)
gitleaks protect --staged

# Scan entire repository history
gitleaks detect --source . --report-path gitleaks-report.json

# Example output when a secret is found:
# ○
#     │╲
#     │ ○
#     ○ ░
#     ░    gitleaks
#
# Finding:     AWS Access Key found
# Secret:      AKIAIOSFODNN7EXAMPLE
# RuleID:      aws-access-key-id
# Entropy:     3.88
# File:        config/aws.py
# Line:        14
# Commit:      a1b2c3d4e5f6...
# Author:      Dev Dev <dev@company.com>
# Date:        2026-06-01T09:00:00Z

Conclusion

Secrets management is one of those disciplines that feels abstract until the moment it isn’t — until a credential leaks, a breach occurs, or a compliance audit reveals sprawl you didn’t know you had.

The good news: the path forward is clear, and you don’t have to implement everything at once.

Start here if you’re a beginner:

  1. Add .env to your .gitignore immediately
  2. Install a pre-commit secret scanner (detect-secrets or GitLeaks) today
  3. Sign up for Doppler or Infisical and migrate your team’s .env files this week

Scale up as you grow:

  1. Move to a cloud-native secrets manager (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager)
  2. Implement RBAC and least-privilege access policies
  3. Enable automated secret rotation for all database credentials
  4. Integrate TruffleHog or GitGuardian into your CI/CD pipeline

Go enterprise when you’re ready:

  1. Deploy HashiCorp Vault for dynamic secrets and multi-cloud
  2. Implement OIDC for all CI/CD pipeline cloud authentication
  3. Build a full audit and alerting pipeline into your SIEM
  4. Adopt the Kubernetes Secrets Store CSI Driver for pod-native secret injection

The key insight: Secrets management is not a one-time project. It’s an ongoing security posture. Credentials rotate, services are decommissioned, teams change. The systems you build to manage secrets today need to be as automated and auditable as your production infrastructure itself.

The tools exist. The best practices are proven. The only thing standing between your organization and a credential leak is the decision to start.


What secrets management tools or practices does your team use? What’s been the hardest part of your journey? The comments are open.


Further Reading


Back to All Posts
Share this post:
Share on Twitter
Share on LinkedIn
Share on Reddit
Share on Facebook
Copy Link
Copied!