Writing secrets
Creating and updating values without leaking them into shell history, editing JSON in place, generating strong secrets, and moving or deleting safely.
Commands on this page: set · patch · gen · cp · mv · delete · undelete
Creating or updating a value#
set writes a new version. There are three ways to supply the value, and two of them keep it out of
your shell history:
pulsvault set acme-web/prod/product/web/db/password --from-file ./pw.txt
cat pw.txt | pulsvault set acme-web/prod/product/web/db/password --from-stdin
pulsvault set acme-web/prod/product/web/db/password 'hunter2' # last resortPrefer --from-file or --from-stdin for anything real. The CLI prints a warning when you pass a value
as an argument on a terminal — that warning is not decorative, the value is now in ~/.bash_history and
likely in your process list.
Two optional flags travel with a write:
pulsvault set acme-web/prod/product/web/db/port 5432 \
--content-type application/json \
--metadata owner=platform --metadata rotates=90d--metadata is repeatable.
--content-type application/json is about type fidelity, not structure: it stores a number,
boolean or list as itself, so 5432 reads back as 5432 rather than "5432". Everything else is
text/plain, which is the default.
--content-type application/json refuses an object:
error: A JSON object ({ }) denotes a folder, not a leaf value (400){ } is reserved to mean a folder — it is how the path tree works — so a set of related credentials
is a folder of secrets, not one blob:
pulsvault set acme-web/prod/product/web/db/creds/user app
pulsvault set acme-web/prod/product/web/db/creds/password s3cr3tThat is also the better shape: each one can be granted, rotated and validated on its own, which a
single JSON document cannot. If you genuinely need one JSON document — something you will patch —
write it with a plain set and no --content-type.
…/db/password is valid; a bare …/password at the namespace root is not. The write is rejected, and
so is one that would make a path both a folder and a secret. See
Paths, plans & roles.
A value with nothing in it is refused#
set will not write an empty — or whitespace-only — value:
cat pw.txt | pulsvault set acme-web/prod/product/web/db/password --from-stdin
cat: pw.txt: No such file or directory
error: stdin was empty — nothing to write.
the command feeding the pipe may have failed — check its exit status
to store an empty value on purpose, pass --allow-emptyThis exists because of how the failure looks in a shell. cat failed, but in a pipeline the exit
status you get back is the last command's — pulsvault's — so the script keeps going. Without the
check, that overwrote a live password with zero bytes and exited 0. The server enforces the same rule,
so a script that builds the request itself cannot slip past it either.
The error names where the value came from, so you know which end to look at:
| How you supplied it | What you see |
|---|---|
--from-stdin |
stdin was empty (or stdin held only whitespace) |
--from-file F |
./pw.txt is empty |
| inline argument | the value is empty |
When empty really is the value you want — an optional variable your app reads and expects to find blank — say so:
pulsvault set acme-web/prod/product/web/flags/legacy_mode '' --allow-empty
wrote acme-web/prod/product/web/flags/legacy_mode (version 1)echo secret > pw.txt leaves a newline at the end of the file, and set stores the file's bytes
exactly as they are. Only a value that is entirely empty or whitespace is refused, so writing from a
file keeps working the way it always has.
The console asks the same question in its own idiom: clear the value in the secret form and a Store
an empty value on purpose checkbox appears under it, which is what --allow-empty is here. Same rule,
same opt-in, whichever surface you write from.
Quotes you type become part of the secret#
set stores the bytes you give it. It does not strip quotes, and it does not trim whitespace — so a
value you wrapped in quotes out of habit is stored with the quotes:
pulsvault set acme-web/prod/product/web/flags/testing '""'
warning: the value is two " characters, not an empty value
to store an empty value, pass an empty argument with --allow-empty
wrote acme-web/prod/product/web/flags/testing (version 1)That secret now holds two characters. pulsvault run exports them verbatim, and the application reads
"" where it expected nothing at all — a misconfiguration that shows up in production rather than at
the point it was typed.
The same warning appears for quotes around real content and for whitespace around it:
| What you typed | What you see |
|---|---|
'""' or "''" |
the value is two " characters, not an empty value |
'"s3cr3t"' |
the " characters around the value are part of the secret |
's3cr3t ' |
the value has leading or trailing whitespace, which is stored with it |
It is a warning, not a refusal. A secret is opaque bytes: "s3cr3t" is a perfectly good password
if that is genuinely the password, and refusing it — or quietly stripping the quotes — would mean the
value you read back is not the value you wrote. The write goes through either way.
It is printed only when a terminal is attached, so CI logs stay quiet whatever the shape.
--content-type application/json is exempt too: there the quotes are JSON syntax and decode away, so
'"s3cr3t"' really does mean the string s3cr3t.
The newline --from-file hands you#
echo s3cr3t > password.txt writes eight bytes, not seven — echo appends a newline, and set
stores the file verbatim:
$ pulsvault set acme-web/prod/product/db/password --from-file password.txt
warning: the value from file:password.txt ends with whitespace, which is stored and injected with it
if that newline is not part of the secret:
printf %s "$(cat FILE)" | pulsvault set <path> --from-stdin
wrote acme-web/prod/product/db/password (version 1)This is the shape worth catching, because nothing downstream catches it for you: pulsvault run
injects DB_PASSWORD=s3cr3t\n, the database rejects the login, and every log you inspect prints the
password looking exactly correct.
Quoting is deliberately not warned about for a file or a pipe — CI writes quoted JSON through one all the time — but trailing whitespace almost never survives on purpose. The write still goes through: if the newline is genuinely part of the secret, nothing is in your way.
pulsvault set … "$PASSWORD" passes the variable's value; pulsvault set … '"$PASSWORD"' passes six
characters including two quotes and a dollar sign. The outer quotes are the shell's and are consumed
by it — anything left inside is yours and is stored.
pulsvault import --format dotenv does strip one pair of surrounding quotes, because FOO="bar"
means bar in a .env file and importing it any other way would be wrong. --format json keeps them,
because a JSON string already says exactly what it contains. set takes no format and makes no such
assumption, which is why it warns instead.
The console asks the same question in its own idiom: type a value shaped this way into the secret form and a Store it exactly as typed checkbox appears under the row, holding the save until you confirm.
Editing one key of a JSON secret#
patch merges instead of replacing — key=value sets a field, key- removes one:
pulsvault patch acme-web/prod/product/web/db/creds username=svc_app legacy_token-This matters for correctness, not just convenience. Reading a JSON secret, editing it locally and
writing it back is a lost-update waiting to happen; patch pins If-Match to the version it just
read, so a concurrent write fails cleanly with a conflict instead of silently overwriting.
To assert a specific version instead, pass it explicitly:
pulsvault patch acme-web/prod/product/web/db/creds password=new --if-match 4--if-match 4 means only if it is still version 4 — a stronger claim than the automatic pin, which
only protects against a write landing between your read and your write.
Generating a strong value#
gen creates a cryptographically random secret client-side and stores it, so no human ever chooses
or sees it unless they ask:
pulsvault gen acme-web/prod/product/web/db/password --length 32
pulsvault gen acme-web/prod/product/web/db/password --length 40 --symbols --copy| Flag | Effect |
|---|---|
--length N |
8–256, default 24 |
--symbols |
include punctuation |
--reveal / --copy |
surface the generated value (otherwise it is stored and never shown) |
--content-type / --metadata |
as for set |
Without --reveal or --copy the value is written and never displayed — which is usually what you want
for a credential that only a machine will ever read.
Copying and moving#
pulsvault cp acme-web/prod/product/web/db/password acme-web/staging/product/web/db/password
pulsvault mv acme-web/prod/product/web/db/password acme-web/prod/product/web/db/main-passwordThe two are less alike than they look, and the difference is worth knowing before you reach for either:
| What happens | Needs | History | |
|---|---|---|---|
cp |
the value is read to your machine and written back at the destination | read on the source, write on the destination |
destination starts at v1 |
mv |
one server-side call relocates the secret | delete on the source and write on the destination |
every version comes with it |
cpduplicates the current value and leaves the source alone. It accepts--if-match Nto guard the destination.mv(aliasesmove,rename) moves the secret atomically — the server checks both permissions before it changes anything, then carries the whole version history in one transaction. It asks for confirmation first;--yes/-yskips the prompt in a script.--if-match Npins the source's current version, so a move loses a race rather than discarding a write that landed while you typed.
Moving a whole folder is a console operation, not a CLI one — see Browsing secrets & folders.
They are separate actions, and no starter permission grants delete except <project>-manage-prod.
A key holding <project>-write-prod can create and update secrets all day and still be refused here:
this key was refused delete on acme-web/prod/product/web/db/password
This key holds: acme-web-write-prod
None of them authorized `delete` here. A permission must cover the path AND
grant the action — `write` does not include `delete`. Check both in the console.Nothing was moved. Attach a permission that grants delete — see
Default permissions.
The move cannot change the project or the environment: {project}/{env} is a fixed mount, and promoting
a secret from dev to prod is a decision that should read as two steps, so do it with get and set.
Moving between namespaces under one mount is fine.
Deleting, and undoing it#
pulsvault delete acme-web/prod/product/web/db/password # soft delete, 30 days to undo
pulsvault undelete acme-web/prod/product/web/db/password # restore itdelete (alias rm) is reversible for 30 days: the secret moves to the namespace's recycle bin
keeping its version history, and undelete (alias restore) brings it back. Reads fail while it is
deleted. After 30 days a nightly job removes it for good and undelete reports a failure rather than
"not found" — the secret did exist, and saying otherwise would send you hunting for a typo.
Destroying a version's ciphertext is an administrator action in the console — restricted and audited. It is recoverable for 30 days and then permanent, or immediately permanent if purged. The CLI deliberately cannot do either. Use it to purge a value that should never have been stored — retention caps handle routine cleanup.
Optimistic locking#
set, patch, cp and rotate all accept --if-match N. The write is rejected with a conflict if the
secret has moved past version N since you read it:
V=$(pulsvault get acme-web/prod/product/web/db/password -o json | jq -r .versionNumber)
# … compute a new value …
pulsvault set acme-web/prod/product/web/db/password --from-stdin --if-match "$V"This is the difference between "write this" and "write this if nothing changed" — the second is what you want whenever two pipelines might touch the same path.
Related#
- Versions & rotation — history, diffing, rolling back, re-keying
- Using in CI — loading a whole file with
import - Secrets & versioning — what the server stores
- Command reference — exact flags