HashiCorp Vault Secrets Management 2026 — Never Hardcode Secrets Again

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Hardcoded secrets in source code caused thousands of breaches in 2025. Static credentials — database passwords stored in .env files, API keys committed to repos, long-lived tokens in CI/CD — are the most common source of data breaches. HashiCorp Vault centralizes secret management so applications never hold long-lived credentials. They request short-lived, auto-rotating secrets at runtime. When a secret leaks, its TTL limits the blast radius.

Install and Initialize Vault

# Install Vault CLI (Ubuntu/Debian)
curl -fsSL https://apt.releases.hashicorp.com/gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
  https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install vault
# vault.hcl — Production config with Raft HA storage
storage "raft" {
  path    = "/opt/vault/data"
  node_id = "node1"
}
 
listener "tcp" {
  address       = "0.0.0.0:8200"
  tls_cert_file = "/opt/vault/tls/tls.crt"
  tls_key_file  = "/opt/vault/tls/tls.key"
}
 
# Auto-unseal with AWS KMS (recommended for production)
seal "awskms" {
  region     = "us-east-1"
  kms_key_id = "alias/vault-unseal"
}
 
api_addr     = "https://vault.internal:8200"
cluster_addr = "https://vault.internal:8201"
ui           = true
export VAULT_ADDR='https://vault.internal:8200'
 
# Initialize (only once — save the output securely!)
vault operator init -key-shares=5 -key-threshold=3
 
# With AWS KMS auto-unseal, Vault unseals automatically on restart
# Without it, unseal manually with 3 of the 5 keys:
vault operator unseal <key-1>
vault operator unseal <key-2>
vault operator unseal <key-3>

KV Secrets Engine

vault secrets enable -path=secret kv-v2
 
# Store secrets
vault kv put secret/myapp/production \
  database_url="postgresql://user:pass@host/db" \
  jwt_secret="$(openssl rand -base64 32)" \
  redis_url="redis://host:6379"
 
# Read
vault kv get secret/myapp/production
vault kv get -field=database_url secret/myapp/production
 
# Version history
vault kv metadata get secret/myapp/production
vault kv rollback -version=2 secret/myapp/production
// vault-client.ts — Read secrets in Node.js
class VaultClient {
  constructor(
    private readonly vaultAddr: string,
    private readonly token: string
  ) {}
 
  async getSecret(path: string): Promise<Record<string, string>> {
    const response = await fetch(`${this.vaultAddr}/v1/${path}`, {
      headers: { 'X-Vault-Token': this.token },
    })
 
    if (!response.ok) {
      throw new Error(`Vault ${response.status}: ${await response.text()}`)
    }
 
    const { data } = await response.json() as any
    return data.data
  }
}
 
// Load secrets at startup, inject into process.env
const vault = new VaultClient(process.env.VAULT_ADDR!, process.env.VAULT_TOKEN!)
const secrets = await vault.getSecret('secret/data/myapp/production')
process.env.DATABASE_URL = secrets.database_url

Dynamic Secrets: Auto-Rotating Database Credentials

Dynamic secrets exist for minutes, not months. Each app instance gets unique credentials that expire automatically:

vault secrets enable database
 
# Configure PostgreSQL backend
vault write database/config/myapp-postgres \
  plugin_name=postgresql-database-plugin \
  allowed_roles="myapp-role" \
  connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/myapp" \
  username="vault-admin" \
  password="$ADMIN_PASSWORD"
 
# Define a role — what credentials look like
vault write database/roles/myapp-role \
  db_name=myapp-postgres \
  creation_statements="
    CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}'
    VALID UNTIL '{{expiration}}';
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public
    TO \"{{name}}\";
    GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"
 
# Generate credentials (auto-deleted after 1 hour)
vault read database/creds/myapp-role
# username: v-token-myapp-XkWjPq
# password: A1a-xH3Kj...
# lease_duration: 1h
 
# Renew before expiry
vault lease renew database/creds/myapp-role/abc123
 
# Revoke immediately on shutdown
vault lease revoke database/creds/myapp-role/abc123

Kubernetes Auth: No Static Tokens

Applications in Kubernetes authenticate using their ServiceAccount JWT — no static Vault tokens needed:

vault auth enable kubernetes
 
vault write auth/kubernetes/config \
  token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
  kubernetes_host="https://kubernetes.default.svc" \
  kubernetes_ca_cert="$(cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt)"
 
# Create policy
vault policy write myapp-policy - <<'EOF'
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
path "database/creds/myapp-role" {
  capabilities = ["read"]
}
EOF
 
# Bind policy to Kubernetes ServiceAccount
vault write auth/kubernetes/role/myapp \
  bound_service_account_names=myapp-sa \
  bound_service_account_namespaces=production \
  policies=myapp-policy \
  ttl=1h
# Vault Agent Sidecar — inject secrets as files
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    metadata:
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "myapp"
        vault.hashicorp.com/agent-inject-secret-config: "secret/data/myapp/production"
        vault.hashicorp.com/agent-inject-template-config: |
          {{- with secret "secret/data/myapp/production" -}}
          DATABASE_URL={{ .Data.data.database_url }}
          REDIS_URL={{ .Data.data.redis_url }}
          {{- end }}
    spec:
      serviceAccountName: myapp-sa
      containers:
        - name: app
          image: myapp:latest
          command: ["/bin/sh", "-c"]
          args:
            - "source /vault/secrets/config && node dist/server.js"

Transit Encryption: Encryption-as-a-Service

vault secrets enable transit
vault write -f transit/keys/pii-key
 
# Encrypt sensitive data
vault write transit/encrypt/pii-key \
  plaintext=$(echo -n "123-45-6789" | base64)
# Returns: vault:v1:ciphertext...
 
# Decrypt
vault write transit/decrypt/pii-key \
  ciphertext="vault:v1:..."
 
# Rotate key (old data still decrypts, new data uses v2)
vault write -f transit/keys/pii-key/rotate

Vault in GitHub Actions

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Get secrets from Vault
        uses: hashicorp/vault-action@v2
        with:
          url: https://vault.internal:8200
          method: jwt
          role: github-actions
          secrets: |
            secret/data/production database_url | DATABASE_URL ;
            secret/data/production aws_key       | AWS_ACCESS_KEY_ID
 
      - name: Deploy
        env:
          DATABASE_URL: ${{ env.DATABASE_URL }}
        run: npm run deploy
# Configure GitHub Actions OIDC in Vault
vault auth enable jwt
 
vault write auth/jwt/config \
  oidc_discovery_url="https://token.actions.githubusercontent.com" \
  bound_issuer="https://token.actions.githubusercontent.com"
 
vault write auth/jwt/role/github-actions \
  role_type="jwt" \
  bound_audiences="https://github.com/myorg" \
  bound_claims.sub="repo:myorg/*:ref:refs/heads/main" \
  policies="deploy-policy" \
  ttl="15m"

Common Mistakes

  • Using root token in applications — create dedicated AppRole or Kubernetes auth tokens with minimal policy scopes
  • Not renewing leases — dynamic secret leases expire; apps must renew them before TTL or they lose database access
  • Storing Vault token in environment variables — tokens can leak via /proc inspection; use the Agent Sidecar to inject secrets as files
  • No audit logging — enable the file audit backend so every secret access is logged for compliance and incident investigation
  • Single Vault node — run 3 or 5 Raft nodes for high availability; a single node is a production single point of failure

Best Practices

  • Enable the file audit log device on every Vault cluster — it records every client request and is required for SOC2/ISO27001
  • Use Vault Agent or the Vault CSI Provider to inject secrets transparently without modifying application code
  • Set short TTLs (1 hour) for dynamic credentials — the shorter the TTL, the smaller the blast radius of a credential leak
  • Use namespaces in Vault Enterprise to isolate secrets between teams and enforce policy boundaries
  • Test Vault HA failover quarterly — kill the leader and verify applications reconnect to the new leader automatically

Key Takeaways

  • Dynamic database credentials from Vault exist for 1 hour — if leaked, they auto-expire with no manual rotation needed
  • Kubernetes auth binds Vault policies to Kubernetes ServiceAccounts — no static tokens required anywhere
  • Transit encryption-as-a-service lets applications encrypt PII without managing encryption keys or key rotation
  • Vault Agent Sidecar injects secrets as files or environment variables into pods without application code changes
  • GitHub Actions OIDC allows CI/CD pipelines to authenticate to Vault without any stored secrets in GitHub
  • The Vault audit log records every secret access with requester identity, timestamp, and path — essential for compliance
  • Auto-unseal with AWS KMS eliminates the manual unseal ceremony after restarts — Vault unseals automatically using the KMS key
  • Vault namespaces (Enterprise) isolate secrets between teams using the same cluster without cross-team access

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading