Pulling Dev Secrets From 1Password CLI at Startup
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.
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
- 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"
- 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.
- Resolve at startup without writing secrets to disk.
op runreads 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.
- 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 }}
- Remove pasted values from existing
.envfiles and point the bootstrap target at the template.
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
Scan for plaintext secrets in
.envfiles in the doctor script and pre-commit hooks; values that look like keys should be references instead.Keep the template complete. Treat
.env.tpllike.env.exampleand check it against the configuration schema, as in keeping .env.example files current.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.
Platform caveats
macOS: the desktop integration uses Touch ID; the first
opcommand in a terminal session prompts for approval.
WSL2: the Windows desktop app integrates with
op.exe; inside WSL, callop.exeor 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 runon 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.