Vault KV-v2: Why put Silently Wipes Every Sibling Field
put replaces the whole secret; patch merges, but even patch cannot remove a field alone.
The problem
A deploy script needs to add a new field to a secret that already has several others in it:
vault kv put secret/app port=5432
This looks harmless. It is not: vault kv put writes a completely new version of the
secret’s data, and that version consists of exactly what was passed on the command line —
nothing carried over from the previous version. If secret/app held user, password and
port before this command, it now holds only port. user and password are gone from
the current version, silently, with no warning and no error, because as far as Vault is
concerned this was a perfectly ordinary write.
This is straightforward to verify:
$ vault kv get -format=json secret/app | jq .data.data
{
"password": "s3cret",
"port": "5432",
"user": "alice"
}
$ vault kv put secret/app user=bob
$ vault kv get -format=json secret/app | jq .data.data
{
"user": "bob"
}
password and port did not fail to update — they were never sent, and put does not
merge, it replaces. The team usually discovers this when whatever consumed password
starts failing, well after the deploy that caused it, which makes the cause non-obvious:
the log shows a successful secret write, not a destructive one.
Working through it
put is a full replace by design, not by oversight
KV-v2 stores each write as a new, numbered version of the whole secret. put is the API’s
“give me the complete data for the next version” operation — there is no partial form of
it, because from the storage engine’s point of view there is no such thing as a partial
version. Anything not included in a put simply is not part of the version being created.
patch: a real, atomic, server-side merge
vault kv patch exists specifically to avoid this. It combines the fields given with the
secret’s existing data and writes the result as a new version, without the caller ever
needing to know what the existing data was:
$ vault kv put secret/app user=alice password=s3cret port=5432
$ vault kv patch secret/app port=5433
$ vault kv get -format=json secret/app | jq .data.data
{
"password": "s3cret",
"port": "5433",
"user": "alice"
}
By default, patch sends an HTTP PATCH request that Vault applies server-side as a JSON
merge (RFC 7396) against the current version, inside the same operation — there is no
separate read step on the client that could race a concurrent writer. An alternative
-method=rw mode exists (read the secret, merge in memory, then put the result) for
older Vault versions or engines without native PATCH support, but it reintroduces exactly
that race and should be treated as a fallback, not the default choice.
Deleting a field is not the same operation as adding one
RFC 7396 JSON Merge Patch — the format patch uses — has a defined way to delete a key:
send that key with a JSON value of null. This does work against the raw API. It does not
work through the CLI’s ordinary key=value shorthand, because vault kv patch app port=
sends port as an empty string, not as JSON null — Vault has no way to tell “delete
this” apart from “set this to empty” through that syntax, and it takes the second
interpretation. Deleting a field means sending real JSON:
$ vault kv patch secret/app port=
$ vault kv get -format=json secret/app | jq .data.data
{
"password": "s3cret",
"port": "",
"user": "alice"
}
port is still present — it is empty, not gone. There is no vault kv delete-field
command, and the CLI gives no direct way to express a merge-patch null. In practice,
removing a single field means treating it as a read-modify-write against the whole
object, which brings back the concurrency question patch was supposed to avoid — so it
needs a concurrency guard of its own.
Guarding the one case that still needs read-modify-write
-cas (check-and-set) makes a write conditional on the version last read, so a concurrent
writer’s change is detected instead of silently lost:
$ vault kv patch -cas=1 secret/app user=charlie
Error writing data to secret/data/app: Error making API request.
...
* check-and-set parameter did not match the current version
That failure, provoked deliberately by giving a stale version number, is -cas doing its
job: the write is refused because the secret has moved on since version 1 was read, rather
than overwriting whatever changed in between.
The solution
A safe single-field delete, using the version read as the check-and-set guard so a concurrent writer’s change is never silently overwritten:
#!/usr/bin/env bash
# delete_field.sh — remove one field from a KV-v2 secret without racing
# another writer, and without touching any other field.
# Usage: ./delete_field.sh secret/app password
set -euo pipefail
SECRET_PATH="$1"
FIELD="$2"
current=$(vault kv get -format=json "$SECRET_PATH")
version=$(echo "$current" | jq -r '.data.metadata.version')
echo "$current" | jq --arg f "$FIELD" '.data.data | del(.[$f])' > /tmp/kv-patch-data.json
vault kv put -cas="$version" "$SECRET_PATH" @/tmp/kv-patch-data.json
rm -f /tmp/kv-patch-data.json
$ ./delete_field.sh secret/app port
$ vault kv get -format=json secret/app | jq .data.data
{
"password": "s3cret",
"user": "alice"
}
If another process wrote to secret/app between the vault kv get and the final
vault kv put, the -cas version mismatch aborts the script with the same check-and-set
error shown above, rather than deleting a field from a version of the secret that no
longer reflects reality.
The general pattern worth carrying forward:
# Adding or updating fields without disturbing the rest: always patch.
vault kv patch secret/app new_field=value
# Never do this against a secret with fields you did not just set:
vault kv put secret/app new_field=value # replaces everything else
Conclusion
Reach for patch, not put, for any write that is not meant to define the entire secret
from scratch — put is correct only when the caller genuinely intends to supply
everything the secret should contain from that point on.
Deletion is not the mirror image of addition in this model: adding or changing a field is
a merge patch’s ordinary case, but removing one requires either raw JSON with an explicit
null against the API directly, or a full read-modify-write guarded by -cas — the CLI’s
convenient shorthand cannot express a delete on its own.
A deploy script that puts a static block of configuration into a secret path is a
reliable way to destroy fields another process owns; when this bites, the fix is usually
not “use patch here” alone but to stop sharing one secret path across owners that do not
coordinate their writes.