BLOG/CONFIGURATION MANAGEMENT
THEME
CONFIGURATION MANAGEMENT

Resolve secrets from Vault, SOPS, or 1Password

OrchStep doesn't reinvent secret storage — it shells out to the tool you already run. One cmd resolver, fetch-once-pull-many with field extraction, and the value stays masked end to end.

May 14, 2026 OrchStep Team 6 minROLE: Security EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/resolve-from-vault
VIEW SOURCE

You already chose a secret manager. Maybe it is HashiCorp Vault, maybe SOPS-encrypted files in git, maybe 1Password or AWS Secrets Manager. The last thing you want is a workflow tool with its own competing notion of where secrets live and its own plugin you have to keep updated for each backend.

OrchStep sidesteps that entirely. Its secret resolver is just a command. Every one of those tools ships a CLI, so OrchStep runs the CLI, takes the output, and treats it as a masked secret. No plugin matrix, no new place to store credentials — your existing tool stays the source of truth.

One resolver, any backend

The same cmd: shape covers every manager — you just change the command:

secrets:
  DB_PASSWORD: { cmd: "vault kv get -field=password secret/db" }   # HashiCorp Vault
  API_KEY:     { cmd: "op read op://prod/api/key" }                # 1Password CLI
  TLS_KEY:     { cmd: "sops -d --extract '[\"tls\"][\"key\"]' secrets.enc.yaml" }  # SOPS
  RDS_PW:      { cmd: "aws secretsmanager get-secret-value --secret-id rds --query SecretString --output text" }

cmd: runs the command and uses its trimmed stdout as the secret value. That value is tainted the instant it's captured — masked in logs, scrubbed from output, excluded from run history.

Auth comes from the environment

Most secret CLIs need to authenticate first (VAULT_ADDR + VAULT_TOKEN, OP_SERVICE_ACCOUNT_TOKEN, AWS creds). A cmd: resolver runs with the workflow's full environment — the host env, anything from dotenv:, your env: values, and other already-resolved secrets — so you provide auth the same way you provide any other variable:

dotenv: [.vault.env]               # VAULT_ADDR=... VAULT_TOKEN=...
secrets:
  DB_PASSWORD: { cmd: "vault kv get -field=password secret/db" }

The auth token can itself be a secret, resolved first and handed to the next command:

secrets:
  VAULT_TOKEN: { env: CI_VAULT_TOKEN }
  DB_PASSWORD: { cmd: "VAULT_TOKEN={{ secrets.VAULT_TOKEN }} vault kv get -field=pw secret/db" }

Fetch once, pull many fields

Most secret stores return a bundle — a JSON blob with several fields. You don't want to run the (slow, rate-limited) CLI once per field. Give several secrets the same command and add a field: to extract each value; identical commands run only once per run and the result is cached:

orchstep.yml
name: resolver
# Fetch once, pull each field. In production the cmd is
#   vault kv get -format=json secret/app   (or `op item get ... --output json`).
# Here a printf stand-in returns the same JSON shape so the demo runs offline.
# Identical cmds run only once per run — the result is cached.
secrets:
  DB_USER:
    cmd: "printf '{\"data\":{\"user\":\"app\",\"pass\":\"s3cr3t\"}}'"
    field: '{{ result.data.user }}'
  DB_PASS:
    cmd: "printf '{\"data\":{\"user\":\"app\",\"pass\":\"s3cr3t\"}}'"
    field: '{{ result.data.pass }}'
tasks:
  migrate:
    # The real values reach psql via the OS env; the run history stays masked.
    env:
      PGUSER: "{{ secrets.DB_USER }}"
      PGPASSWORD: "{{ secrets.DB_PASS }}"
    steps:
      - name: run
        func: shell
        do: 'echo "migrating as user={{ secrets.DB_USER }} pass={{ secrets.DB_PASS }}"'

field: navigates the parsed result — so vault kv get -format=json becomes field: "{{ result.data.data.user }}", and the printf stand-in above (one level shallower) is field: "{{ result.data.user }}". Both DB_USER and DB_PASS share a command, so the fetch happens exactly once. Run it and both values are masked, even though the migration tool gets the real ones:

$ orchstep run migrate
  $ echo "migrating as user=*** pass=***"
migrating as user=*** pass=***

When fetching needs real logic

Sometimes a producer needs a login step, a retry, or a reusable module — more than one command can express. Make the producer a task and pull values from its step outputs with field::

tasks:
  vault-creds:
    steps:
      # Keep login and read in ONE step so they share a shell session.
      - name: fetch
        func: shell
        do: |
          vault login -method=aws >/dev/null
          vault kv get -format=json secret/app
        outputs:
          user: "{{ result.output | fromJson | dig \"data\" \"data\" \"user\" \"\" }}"
          pass: "{{ result.output | fromJson | dig \"data\" \"data\" \"pass\" \"\" }}"

secrets:
  DB_USER: { task: vault-creds, field: "{{ steps.fetch.user }}" }
  DB_PASS: { task: vault-creds, field: "{{ steps.fetch.pass }}" }   # task runs once, not twice

A producer task's output is never displayed — it is secret material by definition. (Each step is its own process, so keep an auth step and the read it enables in the same do:; a vault login in a separate step wouldn't carry over.)

What you get for the wiring

Without OrchStepWith the cmd: resolver
Backend supporta plugin per toolany CLI you already run
Where secrets livea new place to trustyour existing manager, unchanged
Bundle of fieldsN calls, or hand-parsingone fetch, field: per value
Leakage into logs/historyyour problemmasked + excluded automatically
Planning (--dry-run)needs credentialsresolver never runs

Where to go next

The fetch-once demo above runs offline in orchstep-demos — replace the printf with vault kv get -format=json, op item get --output json, or sops -d and nothing else changes.

#SECRETS#VAULT#SOPS#1PASSWORD
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — CONFIGURATION MANAGEMENT