Why this lesson exists
Most of what lives in a company's Vault is static secrets in the key/value engine: API keys, passwords that cannot be dynamic yet, certificates handed over by a vendor. Two things about KV bite everyone once. The secret you write is not at the path you wrote - secret/app/db is stored at secret/data/app/db, and policies must say so. And vault kv put replaces all the keys of a secret, so the colleague who "just updated the password" deleted the username and host. KV v2 keeps versions, which is how you undo that - if you know how.
What you need to know already: the request shape and the dev server (the first lesson of this chapter); tokens and the CLI's token (the tokens lesson); JSON and jq (7.11).
The words you need first
- KV v1 - the original key/value engine: one value per path, no history. Overwritten is gone.
- KV v2 - the versioned key/value engine: every write creates a new version, old versions stay readable until removed, deletes are reversible.
- Version - one numbered state of a secret (1, 2, 3...). The current version is the newest.
- Soft delete - marks a version deleted;
undeletebrings it back. Destroy - removes a version's data for good. - Metadata - per-secret settings and history: current version, max versions, when each version was created or deleted, your own custom metadata.
- Check-and-set (CAS) - "write only if the current version is still N": protection against two writers overwriting each other.
KV v2: one secret, five API paths
Everything you do to a KV v2 secret goes to a different API path under the mount:
| CLI | API path (mount secret/, key shop/orders/db) | capability needed |
|---|---|---|
vault kv put / get / patch | secret/data/shop/orders/db | create / update, read, patch |
vault kv delete (latest) | secret/data/shop/orders/db (DELETE) | delete |
vault kv delete -versions=2 | secret/delete/shop/orders/db | update |
vault kv undelete -versions=2 | secret/undelete/shop/orders/db | update |
vault kv destroy -versions=1 | secret/destroy/shop/orders/db | update |
vault kv list, kv metadata get/put/delete | secret/metadata/shop/orders/db | list, read, update, delete |
The CLI hides this: you type secret/shop/orders/db and it inserts data/ or metadata/ for you. It finds out the mount is KV v2 with a first request (the preflight) to sys/internal/ui/mounts/secret/shop/orders/db. The API, curl, your application's HTTP client and - above all - policies see the real paths. Keep the table in mind for the policy lesson.
The clearer way to write it, which current Vault docs use, names the mount separately:
$ vault kv get -mount=secret shop/orders/db | tail -6
====== Data ======
Key Value
--- -----
host pg.lab
password rotated-2026
username orders_app
-mount=secret + the key inside it: nobody can mistake secret/shop/orders/db for an API path any more.
Writing: put replaces, patch merges
$ vault kv put secret/shop/orders/db username=orders_app password=orders-static-2023 host=pg.lab
======= Secret Path =======
secret/data/shop/orders/db
======= Metadata =======
Key Value
--- -----
created_time 2026-09-22T20:00:03.104380576Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 1
The header prints the real path. Now the most common KV mistake - updating one value with put:
$ vault kv put secret/shop/orders/db password=rotated-2026
$ vault kv get secret/shop/orders/db
======= Secret Path =======
secret/data/shop/orders/db
======= Metadata =======
Key Value
--- -----
created_time 2026-09-22T20:00:03.204172476Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 2
====== Data ======
Key Value
--- -----
password rotated-2026
Version 2 has only password. put writes a complete new version with exactly the keys you gave it. Every app reading username and host broke.
The fix for next time is vault kv patch, which merges into the current version (it needs the patch capability on the data path):
$ vault kv patch secret/shop/orders/db password=rotated-2026
The fix for now is in the history.
Versions: get an old one, roll back
$ vault kv get -version=1 -format=json secret/shop/orders/db | jq .data.data
{
"username": "orders_app",
"password": "orders-static-2023",
"host": "pg.lab"
}
Note the double .data.data in JSON: the response's data holds the secret's data and its metadata. Applications that read KV v2 over HTTP trip on this regularly.
rollback writes an old version's data as a new version - history is never rewritten:
$ vault kv rollback -version=1 secret/shop/orders/db
======= Secret Path =======
secret/data/shop/orders/db
======= Metadata =======
Key Value
--- -----
created_time 2026-09-22T20:00:03.608371752Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 3
Version 3 = version 1's keys. Then vault kv patch ... password=rotated-2026 makes version 4 with all three keys and the new password.
Check-and-set
Two pipelines writing the same secret at the same time: the second one silently wins. -cas=N says "only if the current version is N":
$ vault kv put -cas=1 secret/shop/orders/db password=x
Error writing data to secret/data/shop/orders/db: Error making API request.
URL: PUT https://127.0.0.1:8200/v1/secret/data/shop/orders/db
Code: 400. Errors:
* 1 error occurred:
* check-and-set parameter did not match the current version
-cas=0 means "only if it does not exist yet" - a safe create. vault kv metadata put -cas-required=true makes CAS mandatory for a secret (or cas_required in the mount's config for all of them).
Deleting: soft, undo, destroy, and gone
$ vault kv delete secret/shop/orders/new
Success! Data deleted (if it existed) at: secret/data/shop/orders/new
$ vault kv get secret/shop/orders/new
======= Secret Path =======
secret/data/shop/orders/new
======= Metadata =======
Key Value
--- -----
created_time 2026-09-22T20:00:04.004507676Z
custom_metadata <nil>
deletion_time 2026-09-22T20:00:04.304883376Z
destroyed false
version 1
A soft-deleted version still has metadata, a deletion_time, and no data (the command exits 2). It comes back with vault kv undelete -versions=1 secret/shop/orders/new.
The four levels, from mild to final:
vault kv delete PATH- soft delete the latest version (undo:undelete).vault kv delete -versions=2,3 PATH- soft delete specific versions.vault kv destroy -versions=1 PATH- the data of version 1 is erased; the version stays in the history asdestroyed true. Use it for a value that must not exist anywhere any more - the password someone pasted into the wrong field.vault kv metadata delete PATH- the secret, every version and its metadata: gone.
Metadata
$ vault kv metadata get secret/shop/orders/db
======== Metadata Path ========
secret/metadata/shop/orders/db
========== Metadata ==========
Key Value
--- -----
cas_required false
created_time 2026-09-22T20:00:03.104380576Z
current_version 4
custom_metadata <nil>
delete_version_after 0s
max_versions 0
oldest_version 0
updated_time 2026-09-22T20:00:04.604259076Z
====== Version 1 ======
Key Value
--- -----
created_time 2026-09-22T20:00:03.104380576Z
deletion_time n/a
destroyed true
====== Version 2 ======
Key Value
--- -----
created_time 2026-09-22T20:00:03.204172476Z
deletion_time n/a
destroyed false
...
- max_versions 0 means "the mount default", which is 10: version 11 pushes version 1 out for good. Tune it per secret with
-max-versions. - delete_version_after soft-deletes versions after a duration - for secrets that must not linger.
- custom_metadata - your own key/values: an owner, a rotation date, a ticket.
$ vault kv metadata put -max-versions=5 -custom-metadata=owner=orders -custom-metadata=rotate=quarterly secret/shop/orders/db
Success! Data written to: secret/metadata/shop/orders/db
Listing
$ vault kv list secret/shop/orders
Keys
----
db
new
A key ending in / is a "folder" - a prefix with more keys under it. Listing uses the metadata/ path and the list capability, which reveals key names, never values.
KV v1, and reading the wrong way
A KV v1 mount has no versions, no metadata and no data/ in the path:
$ vault secrets enable -path=legacy kv
Success! Enabled the kv secrets engine at: legacy/
$ vault kv put legacy/app api_key=abc123
Success! Data written to: legacy/app
$ vault kv get legacy/app
Key Value
--- -----
refresh_interval 768h
api_key abc123
refresh_interval is a hint for clients that cache: re-read after this long. Overwrite a v1 secret and the old value is gone. vault kv enable-versioning legacy/ upgrades a v1 mount to v2 in place - and every policy that pointed at legacy/app must now point at legacy/data/app.
The generic vault read / vault write commands do not insert data/. On a v2 mount:
$ vault read secret/shop/orders/db
WARNING! The following warnings were returned from Vault:
* Invalid path for a versioned K/V secrets engine. See the API docs for the
appropriate API endpoints to use. If using the Vault CLI, use 'vault kv get'
for this operation.
No value found at secret/shop/orders/db
$ vault read secret/data/shop/orders/db | head -4
Key Value
--- -----
data map[host:pg.lab password:rotated-2026 username:orders_app]
metadata map[created_time:2026-09-22T20:00:03.704131976Z custom_metadata:<nil> deletion_time: destroyed:false version:4]
map[...] is Go's way of printing a map - remember it, you will see it again when a template renders a secret without saying which field it wants.
Writing values that are not one word
key=@file- the value is the file's contents (a certificate, a JSON blob).@file.json- the whole secret from a JSON object:vault kv put secret/shop/cfg @s.json.-- the JSON comes from stdin, so it never appears on the command line.key=-- one value from stdin.
A password on the command line ends up in shell history and in ps output for the seconds the command runs. In scripts prefer stdin or files with tight permissions.
Which policy does a command need?
-output-policy prints the policy a command needs instead of running it - the quickest way to write the next lesson's policies:
$ vault kv get -output-policy secret/shop/orders/db
path "secret/data/shop/orders/db" {
capabilities = ["read"]
}
In an interview: "Why does a KV v2 policy on secret/app/* not let me read secret/app/db?" - KV v2 stores data under secret/data/ and lists under secret/metadata/; the CLI hides that, policies do not. The policy needs path "secret/data/app/*" for read and path "secret/metadata/app/*" for list.
What you can now do
- Map every
vault kvcommand to its API path and capability. - Update a secret without wiping it (
patch), and undo a badput(-version,rollback). - Use CAS for safe concurrent writes and safe creates.
- Choose between delete, undelete, destroy and metadata delete.
- Read and set metadata: max versions, custom metadata.
- Tell KV v1 from v2, and why
vault read secret/xfails on v2.