HashiCorp Vault
Verified against Official docs — developer.hashicorp.com/vault/docs/commands, · official docs
What it is and where it fits 🎯#
HashiCorp Vault is the most widely deployed, tool-agnostic secrets management platform — the dedicated secrets manager this series' Secrets Management & IAM chapter builds its entire architecture discussion around. Instead of a static database password baked into an environment variable or config file forever, an application authenticates to Vault at runtime, Vault checks that identity against a policy, and hands back exactly the secret(s) that policy allows — logged, auditable, and revocable at any point. Vault's standout capability, and the reason it's worth learning at an architectural level rather than as a product name to recognize, is dynamic secrets: for supported backends (databases, cloud IAM, PKI), Vault doesn't just store a secret someone else created — it generates a brand-new, unique, automatically-expiring credential on demand, per requesting workload.
Three tools in this same cheat-sheet series solve adjacent but distinct problems, and knowing the
boundary matters for an interview or a real design review: Vault is the runtime secrets source of
truth — an application calls its API directly, or gets a token some other way. SOPS (its own cheat
sheet) takes the opposite approach — it encrypts a secret file so it's safe to commit to git and decrypts
it at deploy time, with no server to run. External Secrets Operator (its own cheat sheet) sits between
the two for Kubernetes specifically: it runs inside the cluster and continuously syncs a secret from
Vault (or AWS Secrets Manager, or any supported backend) into a native Kubernetes Secret, so pods never
call Vault's API directly at all.
How a client actually gets a secret from Vault#
The token issued in step 3 is what every subsequent call actually authorizes against — it's short-lived and tied to a policy, never a long-lived static credential a leaked laptop or log file could expose indefinitely.
Installation#
# Debian/Ubuntu — HashiCorp's official apt repository
wget -O- 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
# Manual binary (any platform) — download a pinned version from releases.hashicorp.com/vault
curl -O https://releases.hashicorp.com/vault/1.19.0/vault_1.19.0_linux_amd64.zip
unzip vault_1.19.0_linux_amd64.zip && sudo mv vault /usr/local/bin/
# macOS
brew tap hashicorp/tap && brew install hashicorp/tap/vault
vault versionWarning
Never run vault server -dev outside a local learning environment. Dev mode auto-unseals, stores
everything in memory only (a restart wipes all data), listens on plain HTTP, and starts with a
known root token. It's the fastest way to try Vault's CLI — it is never a production posture.
Core concepts#
| Concept | What it means |
|---|---|
| Secrets engine | A pluggable backend mounted at a path — KV (static key/value), Database (dynamic DB creds), PKI (dynamic TLS certs), Transit (encryption-as-a-service) |
| Auth method | How a client proves identity to Vault — Kubernetes ServiceAccount tokens, AWS IAM, JWT/OIDC, AppRole, LDAP, username/password |
| Policy | An HCL document naming exactly which secret paths an identity may read/write/list — Vault's own least-privilege RBAC layer |
| Token | What a successful auth exchange returns; every API call after that presents this token, not the original credential |
| Lease & TTL | Almost everything Vault issues (a token, a dynamic secret) has a time-to-live and a lease Vault tracks — when the lease expires, Vault (or the backend, for dynamic secrets) revokes it automatically |
| Seal / unseal | Vault encrypts all of its own storage at rest and starts sealed — unable to decrypt anything — until a quorum of unseal keys (or an auto-unseal integration with a cloud KMS) is supplied |
Starting and checking a server#
vault server -config=/etc/vault.d/vault.hcl # real server, using a config file (see below)
vault status # sealed/unsealed, HA state, storage type
vault operator unseal <unseal-key> # supply one key of the configured quorum, repeat until unsealed
vault login <root-or-personal-token> # authenticate the CLI itself for the current shellKV secrets engine (static key/value)#
vault secrets enable -path=secret kv-v2 # mount a KV v2 engine at secret/ (v2 keeps version history)
vault kv put secret/checkout/db-credentials username=svc password="S3cr3t!"
vault kv get secret/checkout/db-credentials # latest version, table output
vault kv get -field=password secret/checkout/db-credentials # a single field, for scripting
vault kv get -version=2 secret/checkout/db-credentials # a specific historical version (KV v2 only)
vault kv patch secret/checkout/db-credentials password="N3wP4ss!" # update one field without clobbering the rest
vault kv metadata get secret/checkout/db-credentials # version history, without revealing values
vault kv delete secret/checkout/db-credentials # soft-delete the latest version (recoverable)
vault kv undelete -versions=3 secret/checkout/db-credentials # restore a soft-deleted version
vault kv destroy -versions=3 secret/checkout/db-credentials # permanently destroy a specific version's data
vault kv list secret/checkout/ # list keys under a pathkv put fully overwrites all fields at that path unless you use kv patch — a common early mistake is
running kv put a second time to add one field and accidentally deleting every other field that was there.
Database secrets engine — dynamic secrets in practice#
This is the exact mechanism the tutorial's checkout-service example builds toward: instead of one shared Postgres password every instance uses forever, Vault creates a brand-new, unique database user per request, with a lease that auto-expires.
vault secrets enable database # mount the database secrets engine at database/
# Tell Vault how to connect, and which Vault roles are allowed to use this connection
vault write database/config/checkout-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="checkout-readonly" \
connection_url="postgresql://{{username}}:{{password}}@postgres:5432/checkout?sslmode=disable" \
username="vault-admin" \
password="admin-bootstrap-password"
# Define a Vault role: a TEMPLATE for creating short-lived SQL users, not a single fixed user
vault write database/roles/checkout-readonly \
db_name=checkout-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl=1h \
max_ttl=24h
vault read database/creds/checkout-readonly # generates a brand-new, unique username/password RIGHT NOW
vault lease renew database/creds/checkout-readonly/<lease_id> # extend before the TTL expires, up to max_ttl
vault lease revoke database/creds/checkout-readonly/<lease_id> # revoke immediately — the SQL user is droppedSample vault read database/creds/checkout-readonly output (representative shape — the actual username
is auto-generated and unique on every call, so it will never repeat):
Key Value
--- -----
lease_id database/creds/checkout-readonly/AbCd1234EfGh5678
lease_duration 1h
lease_renewable true
password A1a-RandomlyGeneratedPassword
username v-token-checkout-r-a1b2c3d4e5f6
Tip
Set max_ttl deliberately, not just default_ttl. A lease can be renewed repeatedly up to
max_ttl, at which point it is force-revoked no matter what — this is your real upper bound on how
long any single dynamic credential can possibly remain valid, which matters for reasoning precisely
about blast radius in a security review.
Policies — least-privilege access to secret paths#
# checkout-policy.hcl
path "secret/data/checkout/*" {
capabilities = ["read", "list"]
}
path "database/creds/checkout-readonly" {
capabilities = ["read"]
}vault policy write checkout-policy checkout-policy.hcl
vault policy read checkout-policy
vault policy listA policy that only grants read/list on the exact paths a workload needs — nothing broader — is
Vault's version of the same least-privilege principle this series applies repeatedly to Kubernetes RBAC
and IAM roles: the blast radius of a compromised token is bounded by what its policy actually allows.
Kubernetes auth method — no static Vault credential in any pod#
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
vault write auth/kubernetes/role/checkout-service \
bound_service_account_names=checkout-service \
bound_service_account_namespaces=default \
policies=checkout-policy \
ttl=1h
# From inside the pod (its own ServiceAccount token is the proof of identity):
vault write auth/kubernetes/login role=checkout-service \
jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)This closes the same "secret zero" loop the tutorial names explicitly: the pod never stores any Vault credential at all — it authenticates using an identity Kubernetes itself already issues and vouches for.
JWT/OIDC auth method — federating CI/CD platforms directly#
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-deploy \
role_type="jwt" \
bound_audiences="https://github.com/my-org" \
bound_claims='{"repository":"my-org/checkout-service","ref":"refs/heads/main"}' \
user_claim="actor" \
policies="checkout-policy" \
ttl=15m
# Inside a GitHub Actions job with id-token: write permission:
VAULT_TOKEN=$(vault write -field=token auth/jwt/login \
role=github-deploy jwt=$ACTIONS_ID_TOKEN_REQUEST_TOKEN)bound_claims is the line doing the real security work — a validly-signed token from a different
repository or branch still fails this check and is rejected, exactly like an IAM role trust-policy
condition scoping which OIDC subject can assume it.
Token management#
vault token create -policy=checkout-policy -ttl=1h # issue a scoped, time-limited token by hand
vault token lookup <token> # inspect a token's policies, TTL, and metadata
vault token renew <token>
vault token revoke <token> # immediately invalidate — and everything it leasedAudit devices — who accessed what, when#
vault audit enable file file_path=/var/log/vault_audit.log
vault audit listImportant
Enable an audit device before going anywhere near production. Without one, Vault still enforces policy correctly, but there's no durable record of which identity read which secret and when — the exact audit trail a SOC 2 or PCI-DSS review (see Part 6 of this series) will ask for as evidence.
Config file format — a minimal production server config#
# vault.hcl
storage "raft" {
path = "/opt/vault/data"
node_id = "vault-node-1"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/etc/vault.d/tls/vault-cert.pem"
tls_key_file = "/etc/vault.d/tls/vault-key.pem"
}
seal "awskms" {
region = "us-east-1"
kms_key_id = "alias/vault-unseal-key"
}
api_addr = "https://vault.internal.example.com:8200"
cluster_addr = "https://vault-node-1.internal.example.com:8201"
ui = trueThe seal "awskms" stanza is what auto-unseal actually is in practice — Vault calls out to a cloud KMS
to decrypt its own master key on startup instead of requiring a human to type in unseal key shares every
time the process restarts, while still keeping the underlying storage itself independently encrypted.
Real-world scenario: killing a shared static database password#
checkout-service currently has one hardcoded Postgres password, identical across every running
instance, sitting in config.py — the exact scenario the tutorial's secrets-management chapter opens
with. Fixing it:
- Enable the database secrets engine and configure the connection (
database/config/checkout-postgres). - Define a Vault role with the minimal SQL grants the service actually needs (
SELECTonly, not superuser) and adefault_ttl/max_ttlpair that matches how long a request cycle realistically runs. - Enable the Kubernetes auth method and bind a role to
checkout-service's exact ServiceAccount and namespace. - Update the application to call
vault read database/creds/checkout-readonlyat startup (or via a sidecar like Vault Agent) instead of reading a password from an environment variable. - Revoke and rotate the old static database user entirely — leaving it active defeats the whole point.
- Enable an audit device so every credential issuance is logged.
CI/CD integration recipe — GitHub Actions, OIDC, no stored Vault token#
# .github/workflows/deploy.yml
name: Deploy checkout-service
on:
push:
branches: [main]
permissions:
id-token: write # required to request the GitHub OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch DB credentials from Vault via JWT auth
run: |
VAULT_TOKEN=$(vault write -field=token auth/jwt/login \
role=github-deploy jwt=$ACTIONS_ID_TOKEN_REQUEST_TOKEN)
export VAULT_TOKEN
DB_PASSWORD=$(vault kv get -field=password secret/checkout/db-credentials)
echo "::add-mask::$DB_PASSWORD"
env:
VAULT_ADDR: https://vault.internal.example.com:8200::add-mask:: matters here specifically because this is the one place the secret briefly exists as a
shell variable inside CI logs — without masking it, a stray echo or failed-step stack trace elsewhere
in the job could leak it straight into the build log.
Common pitfalls#
- Treating a root token as a day-to-day credential. The root token can do anything, including rewriting every policy — generate it once for initial setup, then revoke it and operate through scoped tokens/auth methods for everything else.
- Forgetting
max_ttl. A renewable lease with no practical ceiling can end up effectively long-lived, quietly defeating the point of "dynamic" secrets. - Running
-devmode anywhere reachable from outside a laptop. See the warning under Installation. kv putsilently overwriting fields. Usekv patchfor a partial update on KV v2.- No auto-unseal in production. A server that restarts and needs a human to manually supply unseal keys is an availability risk during an incident, exactly when you can least afford a manual step.
When to reach for something else#
For secrets that need to be safely committed to a GitOps repository rather than fetched from a live API
at runtime, reach for SOPS instead (its own cheat sheet) — no server to run or keep available. For
syncing Vault-held secrets into native Kubernetes Secret objects without every pod needing its own
Vault client logic, reach for External Secrets Operator (its own cheat sheet), which wraps this exact
Vault API underneath a Kubernetes-native CRD. If the team is fully committed to one cloud provider and
doesn't need dynamic secrets' breadth of supported backends, a fully-managed option like AWS Secrets
Manager trades some flexibility for zero operational overhead — see the comparison table in
Part 4 of the DevSecOps tutorial.