Development secrets — a Stripe test key, a sandbox API token, a shared staging database password — are pasted into each developer's .env from a chat message or a wiki page, so rotating one means asking twenty people to edit a file, and nobody knows whose copy is stale. Teams that already use 1Password can keep secrets in a shared vault and resolve them at startup with the op CLI, so .env files hold references, not values. Attempts to do this often stop at [ERROR] You are not currently signed in or "op://Dev/Stripe/secret" isn't an item. This page wires it up end to end, as part of local secret vaults and rotation.

After the change, rotating a secret means updating one vault item; every developer gets the new value on the next start.

Diagnostic

Check the CLI, the sign-in state, and whether the references in the template resolve:

#!/usr/bin/env bash
set -euo pipefail
op --version
op whoami 2>&1 | head -2
grep -oE 'op://[^" ]+' .env.tpl | sort -u | while read -r ref; do
  if op read "$ref" >/dev/null 2>&1; then echo "ok       $ref"; else echo "MISSING  $ref"; fi
done
grep -cE '^[A-Z_]+=(sk_|whsec_|ghp_)' .env 2>/dev/null | xargs echo "plaintext secrets in .env:"

Expected bad output:

2.30.0
[ERROR] 2026/09/18 12:01:44 You are not currently signed in. Please run `op signin --help` for instructions
MISSING  op://Dev/Stripe/secret
MISSING  op://Dev/Shop Staging DB/password
plaintext secrets in .env: 3

The CLI is not connected to the desktop app, so no reference resolves, and the working .env still holds three pasted secrets.

Pasted Secrets vs Vault References Comparison of copying secret values into .env files against committing op:// references resolved at startup. Pasted Secrets vs Vault References secrets pasted in .env op:// references in .env.tpl copied via chat stored in shared vault rotation asks everyone rotate one item stale copies linger always current value values on disk values only in memory
References move the secret to one place; each start fetches the current value.

Root cause

The op CLI authenticates through the 1Password desktop app (biometric unlock) or through a service account token; without either, every command fails with "not signed in". Integration with the desktop app is a setting in the app itself (Settings → Developer → Integrate with 1Password CLI), which new installs do not enable. References use the form op://<vault>/<item>/<field>; vault and item names are case-sensitive, names with spaces must be written exactly, and the field is the field's label (such as password or credential), not its type. Even with references working, .env files still often contain pasted values because nothing replaced them — the reference template exists alongside the old habit. Finally, CI has no desktop app, so the same references need a service account with read access to the vault.

The deeper problem the references solve is ownership. A pasted secret has no owner once it leaves the chat message: nobody knows how many copies exist, who still has the old value after a rotation, or whether a leaver's laptop still holds a working credential. A vault item has an owner, an access list and an audit trail, and a reference in every .env.tpl means every environment reads that one item. Rotation becomes an operation on the vault rather than a request to a team, and offboarding removes access in one place.

Resolution

  1. Connect the CLI to the desktop app once per laptop and verify:
#!/usr/bin/env bash
set -euo pipefail
command -v op >/dev/null || brew install 1password-cli
echo "Enable: 1Password > Settings > Developer > Integrate with 1Password CLI"
op vault list --format json | jq -r '.[].name'
op item get "Stripe" --vault "Dev" --fields label=secret --reveal >/dev/null && echo "vault access ok"
  1. Commit a template with references, never values:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/shop
STRIPE_API_KEY=op://Dev/Stripe/secret
STRIPE_WEBHOOK_SECRET=op://Dev/Stripe/webhook signing secret
STAGING_DB_PASSWORD=op://Dev/Shop Staging DB/password
NPM_TOKEN=op://Dev/GitHub Packages/credential

Save as .env.tpl. Non-secret values stay as plain text; only secrets become references.

  1. Resolve at startup without writing secrets to disk. op run reads the template, resolves references and passes the values as environment variables to the command:
#!/usr/bin/env bash
set -euo pipefail
op run --env-file=.env.tpl -- docker compose up -d --wait

Compose receives the variables from its own environment, so services that interpolate ${STRIPE_API_KEY} get the resolved value and nothing is written to a file. If a tool insists on a file, op inject -i .env.tpl -o .env renders one — make sure .env is git-ignored and delete it when done.

  1. Use a service account in CI with read access to the same vault:
jobs:
  integration:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: 1password/install-cli-action@v1
      - run: op run --env-file=.env.tpl -- npm run test:integration
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
  1. Remove pasted values from existing .env files and point the bootstrap target at the template.
Secrets Resolved at Startup Flow from the committed template through op run and the vault to the running services. Secrets Resolved at Startup .env.tpl op:// refs op run desktop or token vault current values services start env only
Values exist only in the process environment; the repository and disk hold references.

Expected output

$ op run --env-file=.env.tpl -- docker compose up -d --wait
[+] Running 4/4
 ✔ Container shop-db-1      Healthy
 ✔ Container shop-api-1     Healthy
$ docker compose exec -T api sh -c 'echo ${STRIPE_API_KEY:0:8}'
sk_test_
$ grep -c 'op://' .env.tpl; test -f .env && grep -cE '=(sk_|whsec_)' .env || echo "no plaintext secrets on disk"
4
no plaintext secrets on disk

Services receive current secret values, only references are committed, and no plaintext secrets remain on disk. Rotating the Stripe test key in the vault takes effect for everyone on their next start.

op run also masks resolved values in its output: if a command prints a secret to stdout or stderr, the CLI replaces it with <concealed by 1Password>. That is a useful safety net in local terminals and CI logs alike, though it does not replace keeping secrets out of logs in the first place.

Prevention

  1. Scan for plaintext secrets in .env files in the doctor script and pre-commit hooks; values that look like keys should be references instead.

  2. Keep the template complete. Treat .env.tpl like .env.example and check it against the configuration schema, as in keeping .env.example files current.

  3. Scope vault access tightly. A dedicated "Dev" vault with test-mode and sandbox credentials only; production secrets never live in a vault developers' CLIs can read.

Diagnosing an op Failure Decision diagram mapping common 1Password CLI errors to their fixes. Diagnosing an op Failure What does op report? not signed in enable app integration isn't an item check vault and item names no field use the field label
Most failures are the desktop integration or an exact-name mismatch in a reference.

Platform caveats

macOS: the desktop integration uses Touch ID; the first op command in a terminal session prompts for approval.

WSL2: the Windows desktop app integrates with op.exe; inside WSL, call op.exe or install the Linux CLI and use a service account for scripted use.

Dev containers and Codespaces: there is no desktop app inside the container; use op run on the host to start Compose, or a service account token provided through the CDE's secret store.

Apple Silicon (ARM64): the CLI ships native arm64 builds through Homebrew.

Rollback

Render a plain .env once from the template if the team needs to stop using the CLI; references remain in the template for later:

#!/usr/bin/env bash
set -euo pipefail
op inject -i .env.tpl -o .env
chmod 600 .env

Frequently Asked Questions

Why does op say I am not signed in although the app is unlocked?

The CLI integration is disabled in the desktop app. Enable it under Settings → Developer, then rerun the command in a new terminal.

What is the difference between op run and op inject?

op run resolves references into environment variables for one command and never writes them to disk. op inject renders a file with the values filled in, which is useful for tools that only read files but leaves secrets on disk.

How do references handle items with spaces in their names?

Write the name exactly as in 1Password, spaces included, inside the reference. Quote the whole reference in shells. Case matters.

Is this safe for CI?

Yes, with a service account restricted to a development vault. The token is stored in the CI secret store, and op run masks resolved values in logs.