HashiCorp Vault Secrets Management 2026: Never Hardcode Secrets Again

Sanjeev SharmaSanjeev Sharma
7 min read

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

# 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro