The .env problem
Every developer has done this at least once:
OPENAI_API_KEY=sk-proj-abc123...
STRIPE_SECRET_KEY=sk_live_xyz789...
DATABASE_URL=postgres://user:pass@host/db
A plain-text file on your disk. Hopefully in .gitignore. Hopefully not backed up to iCloud. Hopefully not visible when you screen-record your terminal for a bug report. Hopefully not committed that one time you hit git add . while tired.
That's a lot of hoping.
There's also the team version. How do you share these with a colleague? Slack works until someone scrolls back. A pinned message works until someone leaves and you realise you never rotated anything. A shared password manager is probably what you should have done from the start.
1Password secret references
1Password lets you store a pointer to a secret instead of the secret itself. Your .env file ends up looking like this:
OPENAI_API_KEY=op://Development/OpenAI/api_key
STRIPE_SECRET_KEY=op://Development/Stripe/secret_key
DATABASE_URL=op://Development/Postgres/connection_string
The format is op://<vault>/<item>/<field>. None of those strings are secret. You can commit them. You can put them in a README. The actual value lives in 1Password and stays there.
Using the op CLI
Install it:
brew install 1password-cli
op signin
Then the part that makes this worth doing. op run reads your .env file, replaces every op:// reference with the real value at runtime, and runs your command. The real secrets never touch the disk.
op run --env-file=.env -- npm run dev
Your app sees OPENAI_API_KEY=sk-proj-abc123... in its environment, exactly as if you'd pasted it there yourself. What's on disk is still just the reference.
My usual setup
Store secrets in 1Password, one item per service. Sanity, OpenAI, Stripe, whatever you're using.
Put only references in .env.local:
OPENAI_API_KEY=op://Development/OpenAI/api_key
SANITY_API_WRITE_TOKEN=op://Development/Sanity/write_token
Wrap every command that needs secrets:
{
"scripts": {
"dev": "op run --env-file=.env.local -- next dev",
"build": "op run --env-file=.env.local -- next build"
}
}
Commit .env.local.example with the references in it. When someone new clones the repo, they copy it to .env.local and everything works, assuming they have access to the right 1Password vault. No more "hey can you send me the API keys" messages.
Why I bother
No secrets on disk. If I lose my laptop, there isn't a file full of production keys for someone to find.
Rotation becomes a one-person job. Change the value in 1Password, everyone else picks it up the next time they run anything. Compare to the old workflow of posting in Slack and hoping everyone updates their local file.
There's an audit log. Useful after an incident. Useful for compliance if that matters to you. Useful when you're trying to figure out why someone is suddenly getting rate-limited.
Access is just 1Password groups. Add someone to the vault, they're in. Remove them, they're out. Same flow whether it's a new hire, a contractor, or someone leaving.
One thing to avoid
1Password has a sibling command called op inject that writes resolved values into a file. Don't use it for .env files. That puts the secrets back on disk, which is the thing you're trying to avoid. op run is the one you want.
The one catch: op run needs an active 1Password session. On macOS you get a Touch ID prompt the first time in a new shell. On CI, you use a service account token and set OP_SERVICE_ACCOUNT_TOKEN.
The broader point
This works for anything you're currently keeping in .env as plaintext. Database URLs. Webhook secrets. OAuth client secrets. Signing keys. If it can live in 1Password, you can stop keeping a copy of it locally.
It won't eliminate secret leaks. But the dumb ones, the .env file in your Downloads folder, the abandoned branch with a key still in it, those stop happening.