HashiCorp Vault Secrets Management 2026: Never Hardcode Secrets Again
Advertisement
HashiCorp Vault 2026: Your Secrets Fortress
Hardcoded secrets in source code caused thousands of breaches in 2025 alone. Vault centralizes secret management — applications never hold long-lived credentials. They request short-lived, auto-rotating secrets at runtime.
- Install and Initialize Vault
- KV Secrets Engine: Static Secrets
- Dynamic Secrets: Auto-Rotating Database Credentials
- Kubernetes Auth: No Secrets Needed
- Transit Encryption: Encryption-as-a-Service
- Vault in GitHub Actions CI/CD
- PKI: Automatic TLS Certificate Management
Install and Initialize Vault
# Install Vault CLI
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
# Dev mode (local testing only — not for production)
vault server -dev -dev-root-token-id="root"
# Production: deploy with HA (Raft storage)
# vault.hcl
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"
}
api_addr = "https://vault.internal:8200"
cluster_addr = "https://vault.internal:8201"
ui = true
# Initialize and unseal
export VAULT_ADDR='https://vault.internal:8200'
# Initialize (only once)
vault operator init -key-shares=5 -key-threshold=3
# Outputs: 5 unseal keys + root token
# Store keys in separate secure locations!
# Unseal (requires 3 of 5 keys)
vault operator unseal <unseal-key-1>
vault operator unseal <unseal-key-2>
vault operator unseal <unseal-key-3>
# Enable auto-unseal with AWS KMS (recommended for production)
seal "awskms" {
region = "us-east-1"
kms_key_id = "alias/vault-unseal"
}
KV Secrets Engine: Static Secrets
# Enable KV v2 (versioned secrets)
vault secrets enable -path=secret kv-v2
# Store secrets
vault kv put secret/myapp/production \
database_url="postgresql://user:pass@host/db" \
redis_url="redis://host:6379" \
jwt_secret="$(openssl rand -base64 32)"
# Read secrets
vault kv get secret/myapp/production
vault kv get -field=database_url secret/myapp/production
# List versions
vault kv metadata get secret/myapp/production
# Roll back to previous version
vault kv rollback -version=2 secret/myapp/production
# Soft delete (can be undeleted)
vault kv delete secret/myapp/production
# Hard delete (permanent)
vault kv metadata delete secret/myapp/production
// vault-client.ts — Read Vault secrets in Node.js
import fetch from 'node-fetch'
interface VaultSecret {
data: {
data: Record<string, string>
metadata: { version: number; created_time: string }
}
}
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 error: ${response.status} ${await response.text()}`)
}
const secret = await response.json() as VaultSecret
return secret.data.data
}
}
// Usage: load secrets at startup
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
process.env.REDIS_URL = secrets.redis_url
Dynamic Secrets: Auto-Rotating Database Credentials
Dynamic secrets are Vault's superpower — credentials that exist for minutes, not months:
# Enable database secrets engine
vault secrets enable database
# Configure PostgreSQL connection
vault write database/config/myapp-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="myapp-role" \
connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/myapp?sslmode=require" \
username="vault-admin" \
password="$POSTGRES_ADMIN_PASSWORD"
# Create a role: defines 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 (TTL: 1 hour, then auto-deleted)
vault read database/creds/myapp-role
# Key Value
# --- -----
# lease_id database/creds/myapp-role/abc123
# lease_duration 1h
# password A1a-xHgKjS2pQw3mN8rT
# username v-token-myapp-rol-FkWjPqL2KmN
# Renew lease if still needed
vault lease renew database/creds/myapp-role/abc123
# Revoke immediately on application shutdown
vault lease revoke database/creds/myapp-role/abc123
// dynamic-db.ts — App that uses dynamic Vault credentials
import { Pool } from 'pg'
async function getDatabasePool(): Promise<Pool> {
// Get fresh credentials from Vault
const response = await fetch(`${process.env.VAULT_ADDR}/v1/database/creds/myapp-role`, {
headers: { 'X-Vault-Token': process.env.VAULT_TOKEN! },
})
const { data } = await response.json()
const pool = new Pool({
host: process.env.DB_HOST,
database: 'myapp',
user: data.username,
password: data.password,
ssl: { rejectUnauthorized: true },
})
// Renew lease before it expires
const ttlMs = data.lease_duration * 1000 * 0.8 // Renew at 80% of TTL
setTimeout(async () => {
await fetch(`${process.env.VAULT_ADDR}/v1/sys/leases/renew`, {
method: 'POST',
headers: { 'X-Vault-Token': process.env.VAULT_TOKEN! },
body: JSON.stringify({ lease_id: data.lease_id }),
})
}, ttlMs)
return pool
}
Kubernetes Auth: No Secrets Needed
Applications in Kubernetes authenticate via their ServiceAccount JWT — no static tokens:
# Enable Kubernetes auth
vault auth enable kubernetes
# Configure it (run from inside Kubernetes, or with kube config)
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 a 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
# kubernetes/vault-agent.yaml — 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 }}
JWT_SECRET={{ .Data.data.jwt_secret }}
{{- end }}
spec:
serviceAccountName: myapp-sa
containers:
- name: app
image: myapp:latest
command: ["/bin/sh", "-c"]
args:
# Source the injected file as env vars
- "source /vault/secrets/config && node dist/server.js"
Transit Encryption: Encryption-as-a-Service
Use Vault to encrypt data without managing encryption keys:
# Enable Transit engine
vault secrets enable transit
# Create encryption key
vault write -f transit/keys/myapp-key
# Encrypt data
vault write transit/encrypt/myapp-key \
plaintext=$(echo -n "sensitive data" | base64)
# Returns: vault:v1:ciphertext...
# Decrypt data
vault write transit/decrypt/myapp-key \
ciphertext="vault:v1:..."
# Returns base64 plaintext
# Rotate encryption key (old versions still decrypt, new encrypts)
vault write -f transit/keys/myapp-key/rotate
# Re-encrypt old data with new key version
vault write transit/rewrap/myapp-key \
ciphertext="vault:v1:..."
# Returns: vault:v2:...
// transit.ts — Transparent field-level encryption
class EncryptedField {
constructor(private readonly keyName: string) {}
async encrypt(plaintext: string): Promise<string> {
const b64 = Buffer.from(plaintext).toString('base64')
const response = await vaultRequest('POST', `transit/encrypt/${this.keyName}`, {
plaintext: b64,
})
return response.data.ciphertext
}
async decrypt(ciphertext: string): Promise<string> {
const response = await vaultRequest('POST', `transit/decrypt/${this.keyName}`, {
ciphertext,
})
return Buffer.from(response.data.plaintext, 'base64').toString()
}
}
// Usage: encrypt PII before storing in DB
const ssn = new EncryptedField('pii-key')
const encrypted = await ssn.encrypt('123-45-6789')
await db.users.update({ id: userId }, { ssn_encrypted: encrypted })
Vault in GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Deploy
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 ;
secret/data/production aws_secret | AWS_SECRET_ACCESS_KEY
- name: Deploy
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
run: |
npm ci
npm run build
npm run deploy
# GitHub Actions OIDC auth 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/webcoderspeed" \
bound_claims_type="glob" \
bound_claims.sub="repo:webcoderspeed/*:ref:refs/heads/main" \
policies="deploy-policy" \
ttl="15m"
PKI: Automatic TLS Certificate Management
# Issue internal TLS certs automatically
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki # 10 years for root
# Generate root CA
vault write -field=certificate pki/root/generate/internal \
common_name="webcoderspeed Internal CA" \
ttl=87600h > /tmp/root-ca.crt
# Create intermediate CA
vault secrets enable -path=pki_int pki
vault write -format=json pki_int/intermediate/generate/internal \
common_name="webcoderspeed Intermediate CA" | \
jq -r '.data.csr' > /tmp/int.csr
vault write -format=json pki/root/sign-intermediate \
csr=@/tmp/int.csr format=pem_bundle | \
jq -r '.data.certificate' > /tmp/intermediate.crt
vault write pki_int/intermediate/set-signed certificate=@/tmp/intermediate.crt
# Create a role to issue certs
vault write pki_int/roles/internal-services \
allowed_domains="internal,svc.cluster.local" \
allow_subdomains=true \
max_ttl="72h"
# Issue a certificate
vault write pki_int/issue/internal-services \
common_name="myapp.internal" ttl="24h"
Vault transforms secrets from a liability into a managed asset. When every credential is short-lived and auto-rotated, the blast radius of any breach shrinks from catastrophic to containable.
Advertisement