BlogCybersecurity
Cybersecurity

Secrets Management in 2026: Stop Hardcoding Credentials — Use Vault, SOPS, and External Secrets Operator

Hardcoded API keys, database passwords in .env files, and AWS credentials in CI variables — these are ticking time bombs. This guide covers HashiCorp Vault for centralized secrets, Mozilla SOPS for encrypted config files, External Secrets Operator for Kubernetes, and automated secret rotation with real deployment configurations.

S

Sarah Chen

Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.

February 9, 2026
22 min read

In March 2025, a developer at a mid-sized SaaS company committed an AWS IAM key to a public GitHub repository. Within 14 minutes — not hours, minutes — an automated bot detected the key, spun up 47 EC2 instances for cryptocurrency mining, and generated a $28,000 AWS bill before the key was revoked. This is not an unusual story. GitHub's secret scanning service detects over 100 million exposed secrets per year across public repositories. GitGuardian's 2025 State of Secrets Sprawl report found that the average organization has 5.2 hardcoded secrets per developer, with 72% of those secrets still active (not rotated) at the time of detection.

Secrets management is the practice of storing, accessing, and rotating credentials (API keys, database passwords, TLS certificates, SSH keys, OAuth tokens) through dedicated systems rather than hardcoding them in source code, environment variables, or configuration files. This guide covers the three most practical solutions for different scales of operation: SOPS for small teams, Vault for enterprises, and External Secrets Operator for Kubernetes-native workflows.

The Problem with .env Files

Environment variables loaded from .env files are the most common approach to secrets management — and the most dangerous at scale. The problems are fundamental, not cosmetic:

  • No encryption at rest: .env files are plaintext. Anyone with file system access (or a backup) has every secret.
  • No access control: If you can read the file, you have every secret in it. There is no concept of "this developer can access the database password but not the AWS root key."
  • No audit trail: You cannot determine who accessed which secret, when, or how often.
  • No rotation mechanism: Changing a secret means editing the file on every server, restarting every service, and hoping you did not miss one.
  • Git history contamination: Even if you .gitignore the .env file now, it may have been committed in the past. git log --all --full-history -- .env will reveal the truth.

The .env approach works for local development of personal projects. For anything that handles user data, processes payments, or runs in production, you need a real secrets management system.

Solution 1: Mozilla SOPS for Small Teams

SOPS (Secrets OPerationS) by Mozilla encrypts configuration files using age, AWS KMS, GCP KMS, or Azure Key Vault. The encrypted files can be safely committed to Git because only the values are encrypted — the keys (field names) remain visible for easy review and diffing. This is the best solution for teams of 1-20 developers who want encrypted secrets without running additional infrastructure:

# Install SOPS and age:
brew install sops age     # macOS
sudo apt install sops age # Ubuntu (or download from GitHub releases)

# Generate an age key pair:
age-keygen -o ~/.config/sops/age/keys.txt
# The public key is printed — share this with your team.
# The private key stays in this file — never share it.

# Create a SOPS configuration file:
cat > .sops.yaml <<'SOPS'
creation_rules:
  # Production secrets: encrypted with age + AWS KMS
  - path_regex: secrets/production/.*
    age: "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    kms: "arn:aws:kms:us-east-1:123456789:key/abcd-1234"

  # Development secrets: encrypted with age only
  - path_regex: secrets/development/.*
    age: "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
SOPS

# Create an encrypted secrets file:
sops secrets/production/api.yaml
# This opens your editor. Type your secrets in YAML:
# database_url: postgres://user:pass@host:5432/db
# stripe_secret_key: sk_live_xxxxx
# jwt_secret: super-secret-key-here

# When you save and close, SOPS encrypts the values:
cat secrets/production/api.yaml
# database_url: ENC[AES256_GCM,data:xxxx,iv:yyyy,tag:zzzz]
# stripe_secret_key: ENC[AES256_GCM,data:xxxx,iv:yyyy,tag:zzzz]
# jwt_secret: ENC[AES256_GCM,data:xxxx,iv:yyyy,tag:zzzz]

# Decrypt and use in your application:
sops --decrypt secrets/production/api.yaml

# Use in a deployment script:
export DATABASE_URL=$(sops --decrypt --extract '["database_url"]' secrets/production/api.yaml)

# Edit an existing encrypted file:
sops secrets/production/api.yaml
# SOPS decrypts it, opens your editor, and re-encrypts when you save.

SOPS files can be safely committed to Git. The encryption keys never touch the repository — they are stored in KMS or local age key files. When a team member leaves, you rotate the age key and re-encrypt all files. When a secret needs to change, you edit the SOPS file, commit the change, and the deployment pipeline decrypts the updated value automatically.

Solution 2: HashiCorp Vault for Enterprise Teams

Vault is the industry standard for centralized secrets management. It provides dynamic secret generation (creates database credentials on the fly and revokes them after use), fine-grained access control policies, detailed audit logging, and automatic secret rotation. The trade-off is operational complexity — Vault itself needs to be deployed, secured, and maintained:

# Install Vault (production deployment uses Raft storage):
# docker-compose.yml for a single-node Vault:
version: '3.8'
services:
  vault:
    image: hashicorp/vault:1.17
    cap_add:
      - IPC_LOCK
    ports:
      - "8200:8200"
    environment:
      VAULT_ADDR: "http://0.0.0.0:8200"
    volumes:
      - vault-data:/vault/data
      - ./vault-config.hcl:/vault/config/config.hcl
    command: vault server -config=/vault/config/config.hcl

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

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = 0
  tls_cert_file = "/vault/tls/cert.pem"
  tls_key_file  = "/vault/tls/key.pem"
}

api_addr = "https://vault.yourdomain.com:8200"
cluster_addr = "https://vault.yourdomain.com:8201"
ui = true
disable_mlock = false
# Initialize and unseal Vault:
export VAULT_ADDR='https://vault.yourdomain.com:8200'
vault operator init -key-shares=5 -key-threshold=3
# Save the 5 unseal keys and root token SECURELY.
# Distribute unseal keys to different team members.

vault operator unseal  # Run 3 times with different keys

# Enable the KV secrets engine:
vault secrets enable -path=secret kv-v2

# Store a secret:
vault kv put secret/production/database \
    url="postgres://app:s3cureP@ss@db.internal:5432/myapp" \
    readonly_url="postgres://reader:r3adOnly@db-replica.internal:5432/myapp"

# Read a secret:
vault kv get -field=url secret/production/database

# Create a policy for the application:
vault policy write app-production - <<'POLICY'
# Application can read production secrets:
path "secret/data/production/*" {
  capabilities = ["read"]
}
# Application CANNOT list, create, update, or delete:
path "secret/metadata/*" {
  capabilities = ["deny"]
}
POLICY

# Create an AppRole for the application:
vault auth enable approle
vault write auth/approle/role/myapp \
    secret_id_ttl=720h \
    token_ttl=1h \
    token_max_ttl=4h \
    policies="app-production"

# Get Role ID and Secret ID for the application:
vault read auth/approle/role/myapp/role-id
vault write -f auth/approle/role/myapp/secret-id

In your Node.js application, use the official Vault client library:

// vault-client.ts
import Vault from 'node-vault';

const vault = Vault({
  apiVersion: 'v1',
  endpoint: process.env.VAULT_ADDR,
});

async function initializeVault() {
  // Authenticate with AppRole:
  const loginResult = await vault.approleLogin({
    role_id: process.env.VAULT_ROLE_ID,
    secret_id: process.env.VAULT_SECRET_ID,
  });
  vault.token = loginResult.auth.client_token;
}

async function getSecret(path: string, key: string): Promise<string> {
  const result = await vault.read(`secret/data/${path}`);
  return result.data.data[key];
}

// Usage:
await initializeVault();
const dbUrl = await getSecret('production/database', 'url');
// The database URL is never in an env variable, config file, or source code.

Solution 3: External Secrets Operator for Kubernetes

If your workloads run on Kubernetes, External Secrets Operator (ESO) synchronizes secrets from external providers (Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) into Kubernetes Secrets automatically. Your pods consume standard Kubernetes Secrets, but the actual secret values are managed externally:

# Install External Secrets Operator:
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
    -n external-secrets --create-namespace

# Create a SecretStore that connects to Vault:
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: production
spec:
  provider:
    vault:
      server: "https://vault.internal:8200"
      path: "secret"
      version: "v2"
      auth:
        appRole:
          path: "approle"
          roleRef:
            name: vault-approle
            key: role-id
          secretRef:
            name: vault-approle
            key: secret-id

# Create an ExternalSecret that syncs specific secrets:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-secrets
  namespace: production
spec:
  refreshInterval: 5m  # Check for updates every 5 minutes
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: app-secrets  # The Kubernetes Secret that will be created
    creationPolicy: Owner
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: production/database
        property: url
    - secretKey: STRIPE_KEY
      remoteRef:
        key: production/payments
        property: stripe_secret_key

Automated Secret Rotation

Secrets that never rotate are secrets waiting to be compromised. Implement automated rotation for every secret type:

#!/bin/bash
# rotate-db-password.sh — Run weekly via cron or CI/CD
set -euo pipefail

NEW_PASSWORD=$(openssl rand -base64 32)
DB_HOST="db.internal"
DB_USER="app_user"
DB_NAME="myapp"

# Step 1: Set the new password in the database:
PGPASSWORD=$OLD_PASSWORD psql -h $DB_HOST -U postgres -c \
    "ALTER USER $DB_USER PASSWORD '$NEW_PASSWORD';"

# Step 2: Update the secret in Vault:
vault kv put secret/production/database \
    url="postgres://$DB_USER:$NEW_PASSWORD@$DB_HOST:5432/$DB_NAME"

# Step 3: Restart the application pods to pick up the new secret:
# (If using ESO with refreshInterval, this happens automatically)
kubectl rollout restart deployment/myapp -n production

# Step 4: Log the rotation event:
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) Rotated database password for $DB_USER" >> /var/log/secret-rotation.log

The goal of secrets management is simple: no human should ever see a production secret in plaintext, no secret should live longer than its rotation period, and every access should be logged. SOPS gets you encrypted files in Git. Vault gives you centralized management with audit trails. External Secrets Operator bridges Vault to Kubernetes natively. Choose the solution that matches your infrastructure complexity — but choose something beyond .env files. ZeonEdge implements secrets management pipelines as part of our infrastructure security service. Learn about our security architecture services.

S

Sarah Chen

Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.

Ready to Transform Your Infrastructure?

Let's discuss how we can help you achieve similar results.