# HashiCorp Vault Cheat Sheet

> **Tool:** HashiCorp Vault (vault)
> **Category:** Security & Compliance
> **Verified against:** Official docs — developer.hashicorp.com/vault/docs/commands,
> .../vault/docs/secrets/databases/postgresql, .../vault/docs/auth/kubernetes,
> .../vault/docs/auth/jwt — 2026-09-05 (Vault isn't installable in this sandboxed environment; every
> command below is verified against current official docs rather than a local `--help` run — re-verify
> against `vault --help` before trusting an exact flag in a version-sensitive production change)
> **Official docs:** https://developer.hashicorp.com/vault/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](/tutorials/devsecops/04-secrets-management-and-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

```mermaid
sequenceDiagram
    participant App as Application / Pipeline
    participant Vault as Vault Server
    participant Auth as Auth Method<br/>(Kubernetes, JWT/OIDC, AppRole...)
    participant DB as Backing Secrets Engine<br/>(KV, Database, PKI...)

    App->>Vault: 1. Authenticate (present identity proof)
    Vault->>Auth: 2. Verify identity against configured auth method
    Auth-->>Vault: Identity confirmed
    Vault-->>App: 3. Short-lived Vault TOKEN, scoped by POLICY
    App->>Vault: 4. Request a specific secret path, using the token
    Vault->>DB: 5. Read (or dynamically generate) the secret
    DB-->>Vault: Secret / newly-created credential
    Vault-->>App: 6. Returns the secret (access is logged)

    Note over Vault,DB: For a dynamic secrets engine, step 5<br/>creates a brand-new credential right here —<br/>nothing was sitting in storage beforehand.
```

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

```bash
# 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 version
```

> [!WARNING]
> **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

```bash
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 shell
```

## KV secrets engine (static key/value)

```bash
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 path
```

`kv 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.

```bash
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 dropped
```

Sample `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

```hcl
# checkout-policy.hcl
path "secret/data/checkout/*" {
  capabilities = ["read", "list"]
}
path "database/creds/checkout-readonly" {
  capabilities = ["read"]
}
```

```bash
vault policy write checkout-policy checkout-policy.hcl
vault policy read checkout-policy
vault policy list
```

A 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

```bash
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

```bash
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

```bash
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 leased
```

## Audit devices — who accessed what, when

```bash
vault audit enable file file_path=/var/log/vault_audit.log
vault audit list
```

> [!IMPORTANT]
> **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

```hcl
# 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           = true
```

The `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 (`SELECT` only, not
      superuser) and a `default_ttl`/`max_ttl` pair 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-readonly` at 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

```yaml
# .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 `-dev` mode anywhere reachable from outside a laptop.** See the warning under Installation.
- **`kv put` silently overwriting fields.** Use `kv patch` for 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](/tutorials/devsecops/04-secrets-management-and-iam).
