Skip to main content
Version: v5.6.1

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-env names an environment variable holding it; interactive sessions can use --value-prompt (read without echo), and --value-file reads a file. Omit all three and the server generates a value for you (the first line then reads stored 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: the secret-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's cipher_stacking option (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_password is path dev, key db_password, and secret-vault://acme/tf/api_token is path acme/tf, key api_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.SetUserVaultCredential on opschain 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 for opschain.env.DB_PASSWORD, and what does the log print where the value was used?
  • Run a workflow whose api_token input is a vault reference: does opschain runs show <run> -o json record api_token as ******, and did the run succeed? The Academy's vault-reference exercise reads api:workflow_runs:create then api:workflow_runs:success on 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 academy runs the vault-reference exercise against this lesson.

What you learned​

  • vault store/store-file put 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 encrypt produces ciphertext for direct use; vault global decrypts 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, and inputs.webhook != '' tests whether it was; only a non-empty value that is not a vault reference is refused.
  • The local secret store is the --local lane'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).