OnCallReady

Lesson 32.8 · Vault & Secrets Management · 28 min read

KV v2: versions, paths and the put that wipes

In plain words

Picture a filing cabinet where every time you change a document, the old copy is kept in a folder behind it, numbered. You can read any old copy, put an old one back on top, throw one in the bin (but the cleaner will give it back if you ask), or shred it for good. A card on the front of each drawer says how many copies to keep and when the last change was.

That is the KV v2 secrets engine. vault kv put writes a new version, vault kv get -version=N reads an old one, rollback makes an old one current, delete is a soft delete you can undelete, destroy shreds a version, and metadata holds the settings and history. Behind the friendly kv commands, each of those is a different API path, which is the trap the next lesson is about.

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 v2: one secret, five API paths

Everything you do to a KV v2 secret goes to a different API path under the mount:

CLIAPI path (mount secret/, key shop/orders/db)capability needed
vault kv put / get / patchsecret/data/shop/orders/dbcreate / update, read, patch
vault kv delete (latest)secret/data/shop/orders/db (DELETE)delete
vault kv delete -versions=2secret/delete/shop/orders/dbupdate
vault kv undelete -versions=2secret/undelete/shop/orders/dbupdate
vault kv destroy -versions=1secret/destroy/shop/orders/dbupdate
vault kv list, kv metadata get/put/deletesecret/metadata/shop/orders/dblist, 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:

  1. vault kv delete PATH - soft delete the latest version (undo: undelete).
  2. vault kv delete -versions=2,3 PATH - soft delete specific versions.
  3. vault kv destroy -versions=1 PATH - the data of version 1 is erased; the version stays in the history as destroyed true. Use it for a value that must not exist anywhere any more - the password someone pasted into the wrong field.
  4. 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
...
$ 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

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

Why it helps

KV v2 is where most teams keep their static secrets, so you will use it daily. The habits it teaches matter on call: put replaces the whole secret (a forgotten key is gone from the new version), patch merges, check-and-set stops two writers overwriting each other, and a soft delete is undoable while destroy is not.

Knowing that one secret lives under five paths (data/, metadata/, delete/, undelete/, destroy/) is what lets you read a 403 correctly, write policies that actually match, and answer the very common interview question about why a KV v2 policy does not work.

Commands in this lesson

vault

FAQ

Why did my other keys disappear after vault kv put?

Because put writes a complete new version: whatever keys you did not pass are not in it. The old version still has them (vault kv get -version=N), so nothing is lost, but the current secret is what you wrote. Use vault kv patch to change one key and keep the rest.

What is the difference between delete, destroy and metadata delete?

vault kv delete marks the latest version deleted; undelete brings it back. vault kv destroy -versions=N removes that version's data permanently but keeps the history. vault kv metadata delete removes the secret entirely: every version and its metadata, nothing left to recover.

How many versions does Vault keep?

Ten by default per secret; older ones are removed as new ones are written. max_versions can be set on the mount (vault kv metadata config) or per secret with vault kv metadata put -max-versions=N. delete_version_after can also delete versions automatically after a time.

What is check-and-set for?

To stop two writers silently overwriting each other. With -cas=N the write only succeeds if the current version is N; with -cas=0 only if the secret does not exist yet. A mount or secret with cas_required=true refuses writes without it.

How do I know whether a mount is KV v1 or v2?

vault secrets list -detailed shows the options column: map[version:2] for v2. Reading a v2 mount with the raw path (vault read secret/app) also gives a warning that it is a versioned KV store. vault kv enable-versioning upgrades a v1 mount, and that is exactly when old policies stop matching.

In an interview Mid

In KV v2, what is the difference between vault kv delete, undelete, destroy and metadata delete?

KV v2 keeps versions of every secret. vault kv delete is a soft delete: it marks the latest version deleted (deletion_time set), and vault kv undelete -versions=N brings it back. vault kv destroy -versions=N removes the data of those versions permanently, but the history in metadata remains. vault kv metadata delete removes the whole secret: every version and the metadata.

Each maps to its own API path - delete/, undelete/, destroy/, metadata/ - so a policy needs separate rules for each, and after a leak you destroy the leaked version rather than only deleting it.

Also asked: What is the difference between vault kv put and vault kv patch? · How would you prevent two deploy jobs from overwriting the same secret? · How do you roll a secret back to an earlier version?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.