3.5 Secrets and the vault
Included from Basic tiersecrets:multi-vault
After this you can: store a secret in the vault, reference it from properties with secret-vault://, see it masked in a run's log, and use the local secret store on the local lane.
Before you start — tick each one:
- Lesson 3.1 — Your first change (
opschain learn 3.1) - Lesson 1.3 — Properties and settings: the overlay (
opschain learn 1.3)
In the GUI: the Secrets providers panel in the Operations area — provider connections and scope bindings, never values.
A secret never belongs in a properties document, an action file or a
shell history line. In OpsChain the secret's value lives in the
server's secret vault, and everything else — properties, step contexts,
logs — carries only a reference to it, resolved at the moment a step
runs. This lesson stores one value, references it from lesson 1.3's
properties, watches a run use it without ever printing it, and places
the local secret store beside the vault so the two never get confused.
1. Store a secret in the vault
opschain vault store acme/environments/dev \
--path secret-vault://dev/db_password \
--value-env DEV_DB_PASSWORD
stored secret at secret-vault://dev/db_password
stored value (cipher-stacked): {AES2}…
Three rules do the heavy lifting:
- The value never goes on the command line.
--value-envnames an environment variable holding it; interactive sessions can use--value-prompt(read without echo), and--value-filereads a file. Omit all three and the server generates a value for you (the first line then readsstored generated secret at …). - The node path scopes the write. The first argument names the vault
owner — storing needs permission to update that node's secrets path (or
/secrets). The owner does not change where the value lives: thesecret-vault://<path>/<key>URL is the value's address. - What comes back is never the plaintext. The server returns the
stored value cipher-stacked: its own AES layer (the
{AES2}-prefixed blob) applied on top of the vault backend's at-rest encryption, governed by the enabled secrets provider'scipher_stackingoption (default on). A provider configured to store values without the stacked layer returns a placeholder saying so instead.
The vault refuses to overwrite an existing value — re-running the command
is a named error until you pass --replace, which is how rotation stays a
deliberate act. Whole files (an SSH key, a kubeconfig) go in with
vault store-file; binary content is Base64-encoded by the server, text
is stored as-is:
opschain vault store-file acme/environments/dev \
--path secret-vault://dev/deploy_key -f ~/.ssh/id_deploy
stored secret at secret-vault://dev/deploy_key
stored value (cipher-stacked): {AES2}…
Which vault answers depends on the server's enabled secrets-provider
connection (opschain admin secrets providers; the guide is
opschain learn secrets-providers): an OpenBao / HashiCorp Vault KV
engine in production setups, or the server's own encrypted file store
when no provider is registered — the commands and references are
identical either way. Under multi-vault (the secrets:multi-vault
entitlement) an estate holds several enabled providers, with scope
bindings (opschain admin secrets bindings) routing resolution by node
position: an environment's binding beats its project's, which beats the
estate default, so the SAME unprefixed reference resolves against the
payments vault in payments-prod and the shared vault in dev with zero
reference churn. The rare cross-provider read names its provider
explicitly — secret-vault://<provider-code>@<path>/<key> — the escape
hatch, never the common path.
Writes never follow the bindings: an unqualified vault store /
vault store-file URL always lands in the platform's default provider,
and the provider-qualified form writes through the named provider —
authenticated as you, with your stored credential for that provider. An
unknown or disabled provider code, or a missing personal credential, is a
named refusal: a qualified write is never silently redirected to a
different vault.
2. Reference the secret from properties
A reference is a property value that is, in its entirety, the secret's URL:
{
"opschain": {
"env": {
"DB_PASSWORD": "secret-vault://dev/db_password"
}
}
}
Store that on the environment (opschain properties edit acme/environments/dev -f props.json — lesson 1.3) and every change under
it carries the reference, not the value. Just before each step executes,
the server resolves every reference in the step's converged properties
and registers the resolved values with the log masker — so a
opschain.env entry lands in the step's environment as the real value,
while logs, events and the stored properties only ever show
secret-vault://dev/db_password. Run a change whose step echoes the
variable and read its log:
opschain logs <run-id>
[db:connect/check] connecting with password ******
The same goes for file-type properties (opschain.files): the reference
stands in for the file's content until the step materialises it.
Two syntax facts worth knowing, straight from the resolver:
- The reference must be the whole value —
secret-vault://…embedded inside a longer string is not resolved. - The URL carries a path and a key, split on the last slash:
secret-vault://dev/db_passwordis pathdev, keydb_password, andsecret-vault://acme/tf/api_tokenis pathacme/tf, keyapi_token.
Because the reference is a pointer, not a secret, it is safe wherever
properties appear — properties show, runs converged (lesson 1.3) and
exports all show the reference itself.
3. Encrypt a value for direct use
vault encrypt cipher-stacks a value with the server's keys and prints
the ciphertext — for settings or properties fields that accept encrypted
values directly:
opschain vault encrypt --value-prompt
{AES2}…
It needs the update right on /secrets; the plaintext never echoes, and
a blank value or a secret-vault:// reference comes back unchanged.
4. The global read, and its guards
vault global is the read lane, deliberately awkward: it decrypts one
vault value only for a caller who can already prove the stored
(encrypted) form — pass the vault store output back via
--expected-env — and is gated on /secrets permissions:
opschain vault global acme/environments/dev \
--path secret-vault://dev/db_password --expected-env STORED_VALUE
The decrypted plaintext is the command's stdout, because it is the requested output — redirect accordingly, and prefer references over reading values back at all.
Two further guards sit on the interactive read lane, because reading a secret back is the sensitive act:
- Vault reads ride YOUR credential, never the platform's. On an
estate whose secrets live in a registered provider (OpenBao/HashiCorp
Vault), the decrypt and global reads authenticate to the vault as you —
the vault's own ACLs decide what you may read. Store your credential
once (
SecretService.SetUserVaultCredentialonopschain docs api-v2, or the GUI secrets tool's credential setting); without one the read refuses with a typed error telling you so. Platform resolution (references into steps) is unaffected. The local dev file store needs no credential. - Decrypt-for-display sits on the step-up floor: an interactive
session must be elevated (
opschain elevate, a second-factor prove-up — lesson 7.4) before the decrypt verbs serve, and every decrypt and vault read is audited — the event stream records who read which address, never the value.
5. Secret-typed run inputs
Typed run inputs (lesson 4.1) have a secret type, and it speaks the
same reference syntax end to end. Declare it in the workflow:
inputs:
api_token: { type: secret, required: true, description: "vault reference to the API token" }
steps:
- type: change
name: deploy
target: /projects/acme/environments/dev/assets/web
action: web:deploy
with:
token: ${{ inputs.api_token }}
Supply it at start — the value must be a secret-vault:// reference;
a raw secret value is refused before the run exists:
opschain runs create workflow acme/release -i api_token=secret-vault://dev/api_token --follow
And watch the masking hold everywhere the run is written down: the run
document records the input as ******, a --plan payload masks it
before evaluating, condition-verdict operands show ******, and the
reference resolves to the real value only inside the executing step —
where the log masker already knows it:
opschain runs show <run-id> -o json | grep -A2 '"inputs"'
"inputs": {
"api_token": "******"
},
The pointer travels; the value appears only at execution, already registered for masking. That is the whole pattern.
6. The local secret store
opschain secret set|get|ls|delete (lesson 2.1's world) manages a
different thing: a per-user, per-machine store for local runs — the
--local lane where no server is involved. Local runs resolve the same
secret-vault://<path>/<key> references through that store, so an action
authored against the vault works unchanged on your laptop; the store's
master key sits in your OS keychain (or a key file with --no-keychain).
Nothing in it ever reaches a server, and vault never touches it — the
two stores share a reference syntax, not data.
Check yourself
- For the change that used your reference: what does
opschain runs converged <run-id>show foropschain.env.DB_PASSWORD, and what does the log print where the value was used? - Run a workflow whose
api_tokeninput is a vault reference: doesopschain runs show <run> -o jsonrecordapi_tokenas******, and did the run succeed? The Academy'svault-referenceexercise readsapi:workflow_runs:createthenapi:workflow_runs:successon your project and that masked input — no event records a vault write, so the masked input is the evidence that the reference travelled. - Verified version:
opschain academyruns thevault-referenceexercise against this lesson.
What you learned
vault store/store-fileput values in the server's vault: values arrive via env var, no-echo prompt or file (never argv), writes are scoped by the owner node's secrets permissions, overwrites need--replace, and the server only ever returns the encrypted form.- Properties carry
secret-vault://<path>/<key>references — whole-value strings, path/key split on the last slash — resolved into the step just before it runs and masked in its logs. vault encryptproduces ciphertext for direct use;vault globaldecrypts one value only against proof of the stored form, as you, on the step-up floor, audited.- A
secret-typed run input takes vault references only, records as******in every run document and plan, and resolves only inside the executing step. Leaving an optional secret input unset (or empty) means "not supplied" — its default applies, or it binds the empty string and records as""— so a workflow can pass${{ inputs.webhook }}to a change step whether or not the caller set it, andinputs.webhook != ''tests whether it was; only a non-empty value that is not a vault reference is refused. - The local
secretstore is the--locallane's resolver for the same references: per-user, on your machine, separate from any server.
Next: lesson 3.6 — Artifacts (opschain learn 3.6), or back to your track (opschain learn).