Skip to content
varsafe
Esc
navigateopen⌘Jpreview
On this page

Composed Secrets

Build one secret out of others with ${KEY} references, so rotating a password updates every connection string that embeds it.

CLI v7.2.7

A composed secret is a pointer, not a copy. Instead of storing a connection string that happens to contain your database password, you store the shape of it and name the password:

DATABASE_URL = postgres://${DB_USER}:${DB_PASS}@db.internal/app

Rotate DB_PASS once and every secret that references it changes with it. Nothing else to find, nothing else to update.

Why this exists

Without composition, a password that appears in four connection strings exists in five places. Rotating it means editing all five, and missing one produces a partial outage that reads like a credential bug — the service is up, the password is correct, and one worker is still using the old one.

This is not .env variable expansion. varsafe does not expand ${...} in values by default, and it never has (see What composition is not). Composition is something you opt into per secret.

Writing one

In the dashboard, switch the value field to Composed, then type ${ to pick a key. The mode has to be chosen: a plain value containing ${...} is stored as those exact characters, so the dashboard never reinterprets one for you.

From the CLI, mark the value as composed:

varsafe set DATABASE_URL --template 'postgres://${DB_USER}:${DB_PASS}@db.internal/app'

The grammar

${KEY} and nothing else. Every omission is deliberate:

Not supported Why
$KEY (unbraced) No unambiguous end. $FOO_BAR cannot be told from ${FOO}_BAR, so renaming a key would silently change which secret a value points at.
${VAR:-default} An empty value is a legal secret, so a default cannot tell “unset” from “deliberately empty” — it would quietly paper over a missing production secret with a fallback that looks like it worked.
process.env fallback Resolution would depend on the machine doing the read, so CI and a laptop would disagree about what a secret is.
Cross-environment / cross-project Keeps the whole dependency graph inside one tenant boundary, so reading a composed secret authorizes exactly like reading an ordinary one.

To include a literal ${ in a composed value, escape it: \${. A backslash anywhere else stays a backslash, so Windows paths and C:\$Recycle.Bin need no special handling.

References must be UPPER_SNAKE_CASE, matching the rule for secret keys — a template can only ever name something that could exist.

Limits

A composition may reference up to 32 distinct keys with at most 64 placeholders, chained up to 8 deep, and may resolve against at most 64 secrets in total. A resolved value is capped at 64KB and a whole resolved environment at 1MiB. Cycles are rejected outright, including the one-step A = ${A}.

The 64KB cap applies to the resolved value, which is only computed when the secret is read. A short composition can therefore save successfully and still exceed the cap once its references are substituted in — the read is what refuses it, not the write.

Reading one

Everything that delivers values gives you the resolved result:

varsafe run -- ./server          # DATABASE_URL=postgres://alice:hunter2@db.internal/app
varsafe export -f env            # resolved
varsafe get DATABASE_URL         # resolved

That default is load-bearing. A CLI binary already installed somewhere cannot ask for anything else, so resolving is what keeps an upgrade from shipping the literal text ${DB_PASS} into a running deployment.

To see what was written rather than what it resolves to, ask for the source:

varsafe get DATABASE_URL --source     # postgres://${DB_USER}:${DB_PASS}@db.internal/app
varsafe list --reveal --source

varsafe list --reveal marks each secret as composed or literal.

Resolution is all-or-nothing

If any part of a composed value cannot be resolved — a missing reference, a cycle, a limit exceeded — the read fails. It never returns a partial result and never hands back a literal ${...}.

This is deliberate. A half-resolved connection string is worse than an error: the process starts, connects to the wrong place or fails to authenticate, and the cause is many layers away from the config that produced it.

Deleting a secret that others reference

varsafe refuses, and asks what should happen to the dependents:

$ varsafe unset DB_PASS
1 secret(s) are composed from DB_PASS: DATABASE_URL.
Choose flatten to keep them working by copying this value into each of them,
or cascade to delete them as well.

Two answers, named by consequence:

varsafe unset DB_PASS --flatten   # DATABASE_URL keeps working; the value is copied into it
varsafe unset DB_PASS --cascade   # DATABASE_URL is deleted too

There is deliberately no default and no prompt-and-guess. A CI job that picked for itself would either propagate a compromised value or take a service’s config away, with nobody in the loop.

The dashboard asks the same question, listing the secrets that would be affected before you choose.

--cascade follows the chain: if REPORT is composed from APP_CONFIG and APP_CONFIG from DB_PASS, all three go. Stopping partway would leave a reference to something that no longer exists.

The set is computed at the moment of the delete, not when you previewed it. If someone adds a secret referencing DB_PASS while you are deciding, that secret is deleted too — cascade means “this key and everything that depends on it”, and what depends on it is a fact about the environment right then.

Because of that, the result always names what actually went:

varsafe unset DB_PASS --cascade --json
{
  "key": "DB_PASS",
  "removed": true,
  "alsoRemoved": ["APP_CONFIG", "DATABASE_URL"],
  "flattened": [],
  "project": "my-project",
  "environment": "production"
}

Without --json the same information is printed as a warning. --flatten reports the secrets whose values it copied into, under flattened — those are plain values now, not references.

A bulk delete computes impact against the whole batch, so tearing down a service’s interdependent config raises nothing. It refuses only if a secret outside the batch would be left pointing at one inside it.

What composition is not

  • Values are not expanded by default. A literal secret containing ${DB_PASS} stays exactly those bytes, forever, on every read. Only a secret explicitly marked as composed is ever resolved.
  • Secrets that existed before composition are literal. Nothing was reinterpreted. A value that happens to look like a template is still a value.
  • There is no shell interpolation. varsafe run executes without a shell, so $VAR in a command is not expanded either.

This matters if you store templates as data — Grafana dashboards, Kubernetes manifests, another tool’s config. Pasting ${DS_PROMETHEUS} into a value stores that text and returns that text.

Rotating

Rotation is where composition pays for itself. Change the referenced secret:

varsafe set DB_PASS --stdin < new-password.txt

Every composed secret that references it now resolves to the new value. No other secret changed, and nothing needs finding — which is the whole point, because the failure mode without composition is missing one of the copies.

Rotation is also available from the dashboard and through the API’s rotate endpoint. Rotating a composed secret is refused: a composition has no value of its own to rotate, since the value lives in what it references. Rotate what it points at instead.

Auditing

Reading one composed secret discloses every secret it embeds. The audit trail records the whole closure, not just the key you asked for: reading DATABASE_URL is logged as access to DATABASE_URL, DB_USER and DB_PASS. A compromise review that saw only the requested key would clear a password that in fact leaked.

The 64-secret closure limit exists partly for this reason — it sits below the cap on names one audit event retains, so a composed read’s disclosure is always recorded in full rather than truncated.

Availability

Composition is rolled out per deployment and is off until every serving instance can read a composed value. Self-hosted operators enable it with SECRET_COMPOSITION_ENABLED after completing the storage-format migration — the ordering is what makes it safe, not the flag.

While it is off, the dashboard does not offer to compose a secret, and any composition made earlier keeps resolving normally but cannot be edited as a composition — the gate refuses every write that carries one, not only new ones. The edit form says so and offers to convert it to a plain value, which is a one-way move while the gate stays closed.