HashiCorp Vault Secrets Management 2026 — Never Hardcode Secrets Again
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 = trueexport 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_urlDynamic 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/abc123Kubernetes 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/rotateVault 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
/procinspection; 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
Related reading
Container Security — From Dockerfile to Runtime Protection8 min readKubernetes Secrets Management — External Secrets Operator, Vault, and Sealed Secrets Compared7 min readZero Trust Architecture for Backend Systems — Never Trust, Always Verify7 min readAI Tools for DevOps — Generate Dockerfiles, CI/CD Pipelines, and Kubernetes Manifests5 min readDevOps Complete Roadmap 2025 — From Zero to Production Engineer6 min readDocker Best Practices 2025 — Production Checklist for Secure, Lean Images6 min read