Secrets without leaks: the conduit model
A workflow tool should never store your credentials. OrchStep is a conduit, not a vault: it resolves a secret from your own tool at the moment it's needed, hands the real value over, and masks it everywhere else.
blog/conduit-not-vaultEvery workflow tool eventually asks you to put a secret somewhere. The dangerous answer is "in the config." The moment a credential lives in YAML — even encrypted, even in a special block — it gets copied into logs, captured in run history, printed by a tool that doesn't know better, and pasted into a Slack thread during an incident.
OrchStep's stance is blunt: it is a conduit for secrets, never a vault. It does not store your credentials, encrypt them at rest, or let you paste them into a workflow. A secret is always a reference to a resolver, never a literal — you cannot hardcode one. Instead OrchStep runs a command to fetch the value from whatever tool you already use, hands the real value to the thing that needs it, and guarantees it never leaks anywhere else.
Three namespaces, one of them special
{{ secrets.X }} sits alongside {{ vars.X }} and {{ env.X }}, and the difference is the whole point:
{{ vars.X }} | {{ env.X }} | {{ secrets.X }} | |
|---|---|---|---|
| What it is | config the workflow consumes | OS env for external tools | a credential fetched from your tool |
| In the run history? | yes | yes (unless masked) | never |
| Shown in logs/output? | yes | yes (unless masked) | never — always *** |
| Resolved | at load | at load | lazily, once, on first reference |
That second-to-last row is the strong guarantee. Once a value is known to be a secret, OrchStep replaces that exact string with *** anywhere it appears — even if a tool prints the secret itself, the output is scrubbed.
Declare where it comes from, not what it is
A secrets: block is a map of resolvers. Every entry says how to fetch, never the value:
secrets:
DB_PASSWORD: { cmd: "op read op://prod/db/password" } # 1Password CLI
API_TOKEN: { cmd: "vault kv get -field=token secret/api" } # HashiCorp Vault
AWS_SECRET: { cmd: "aws secretsmanager get-secret-value --secret-id db --query SecretString --output text" }A cmd: resolver runs the command and uses its trimmed stdout as the secret. Any secret manager works, because they all ship a CLI — Vault, SOPS, 1Password, AWS Secrets Manager, gcloud secrets, pass, or your own script. OrchStep needs no plugin per tool.
A runnable version
You can prove the masking to yourself without any secret tooling installed — swap the real CLI for a printf stand-in:
name: conduit
# A secret is always a reference to a resolver, never a literal in YAML.
# The cmd here is a stand-in (printf) so the demo runs with no tooling installed;
# in real life it is `op read ...`, `vault kv get ...`, `aws secretsmanager ...`.
secrets:
API_TOKEN: { cmd: "printf 'tok_abc123'" }
tasks:
call:
# The tool receives the REAL value via AUTH; logs and history only ever show ***.
env:
AUTH: "{{ secrets.API_TOKEN }}"
steps:
- name: use
func: shell
do: 'echo "calling api with token={{ secrets.API_TOKEN }}"'
- name: confirm
func: shell
do: 'echo "the tool got the real value via $AUTH"'Run it and every surface is masked — but the tool behind $AUTH still receives tok_abc123:
$ orchstep run call
$ echo "calling api with token=***"
calling api with token=***
$ echo "the tool got the real value via ***"
the tool got the real value via ***Masking never changes what the tool receives. psql, curl, your deploy CLI — they get the real value. Your logs, your captured history, and your terminal get ***.
It costs nothing until you use it
Secrets resolve lazily — a secret nobody references is never fetched. So a secrets: block declared at the workflow level is free during a task that doesn't touch it, and a dev run that never hits production needs no production credentials. A --dry-run shows ⟨secret:NAME⟩ in the plan and never runs the resolver, so planning needs no access at all.
What is guaranteed
| Surface | What you get |
|---|---|
| The tool that needs it | the real value |
| Command echo in logs | masked (psql ... ***) |
| Command output | masked — even if a tool prints the secret, it is scrubbed to *** |
--dry-run plan | ⟨secret:NAME⟩; resolver never runs |
| The run history | the secret is excluded entirely |
And if a workflow references a {{ secrets.NAME }} that isn't declared in scope, the run fails fast before the step executes — no silent empty value reaching a command.
One caveat OrchStep can't fix for you
A secret interpolated directly into a shell command is on that process's argument list, which other users can see via ps. Hand secrets to tools through env: (as AUTH above), not as --password=... arguments. OrchStep masks its own surfaces; it can't un-leak your OS process table.
Where to go next
- Secrets — the full
secrets:surface, producers, and field extraction - Resolve secrets from Vault, SOPS, or 1Password — wiring real tools
- Environment Variables —
env:and name-based masking as a backstop
The masking demo above is runnable in orchstep-demos — swap the printf for your own secret CLI and the guarantees are identical.
curl -fsSL https://orchstep.dev/install.sh | sh