What's this post about?
At coiai, our main line of work is building mockups, and up until now our dev team has been just one or two people. But we've started taking on projects with six or more developers, which forced us to rethink our development environment.
Up to now, we'd been managing environment variables in .env files. Going forward, though, we wanted to make sure secrets could never be read as plain text, and that sharing environment variables across the team happened securely — so we overhauled our setup with those two goals in mind.
Why we adopted Bitwarden
- Stop keeping secrets (API keys, OAuth client credentials, etc.) as plain text in
.env, and move them to
Bitwarden Secrets Manager instead - Using Bitwarden's recommended
bws run, secrets get injected into the process as
environment variables only at the moment of execution, so nothing plain-text ever touches disk - As long as your config-loading logic already prefers environment variables over the
.envfile,
existing scripts and docker-compose setups can migrate with zero code changes - Sharing with the team becomes as simple as handing over a read-only access token
Background: what's wrong with .env?
Pairing .env with .gitignore is the standard way to handle local dev secrets, but it has a few weak points.
- Distribution is manual — you end up handing
.envover via chat or a USB stick to new team members or new machines.
The moment you do, another copy exists, and rotating a secret means redistributing it to everyone all over again - It sits on disk as plain text — at risk of ending up in backups, file shares, or an accidental commit
- AI coding agents can read it — an agent with shell or file access, like Claude Code
or Cursor, will runcat .envorprintenvin the course of debugging something, with no
malicious intent required. Bitwarden itself has called this out in a blog post
(Your coding agent can read your .env file)
A secrets management service solves problems 1 and 2, and Bitwarden Secrets Manager stands out
for being cheap (it's a separate contract from Password Manager) and easy to set up, since the CLI ships as a single binary.
The core concepts to know in Bitwarden Secrets Manager
One important note: this is a completely separate product from Password Manager (personal password storage)! We already use Password Manager at coiai — this time we added a separate contract for Secrets Manager.
There are only three concepts you need to know.
| Concept | Role |
|---|---|
| Project | A container for secrets. In our case, one repo = one project |
| Secret | A KEY / VALUE / note triple. Each line in .env becomes one secret |
| Machine account | An account for programs, not people. You grant read or read-write permissions per project, which issues an access token |
Secrets Manager プロジェクト「MyProject」
├─ GOOGLE_CLIENT_ID = xxxx.apps.googleusercontent.com
├─ GOOGLE_CLIENT_SECRET = GOCSPX-xxxx
├─ POSTGRES_PASSWORD = xxxx
└─ ...
▲ 読み書き: 管理者用マシンアカウント(自分)
▲ 読み取りのみ: メンバー用マシンアカウント(チーム配布)
Setup steps (Windows)
1. On the Web Vault side
- Create a project in Secrets Manager (e.g.,
MyProject) - Create a machine account → add the project under the "Projects" tab, and
set the permission to "Can read, can write" (for the account you'll hand out to team members, use a separate account set to "Can read" only) - Issue a token under the "Access Tokens" tab (it looks like
0.xxxxxxxx...)
2. Installing the bws CLI
bws is a single Rust binary. It's not on winget, so grab it from the
GitHub releases page —bws-x86_64-pc-windows-msvc-<version>.zip — extract it somewhere like%LOCALAPPDATA%\Programs\bws, and add that folder to your PATH.
$dir = "$env:LOCALAPPDATA\Programs\bws"
New-Item -ItemType Directory -Force $dir | Out-Null
Invoke-WebRequest "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.1.0/bws-x86_64-pc-windows-msvc-2.1.0.zip" -OutFile "$env:TEMP\bws.zip"
Expand-Archive "$env:TEMP\bws.zip" $dir -Force
# PATH への追加(ユーザー環境変数)
[Environment]::SetEnvironmentVariable("Path",
[Environment]::GetEnvironmentVariable("Path","User") + ";$dir", "User")
3. Storing the access token
Don't write the token to a file — store it as a user environment variable instead.
setx BWS_ACCESS_TOKEN "0.xxxxxxxx-xxxx-...."
If you're on an EU server contract, you'll also need to set the server URL:bws config server-base https://vault.bitwarden.eu
4. Bulk-registering an existing .env
Just repeat bws secret create KEY VALUE <project-id> for each entry.
If you want to bulk-register straight from an existing .env, a one-liner like this will do
(we ended up turning this into a helper script that supports push/pull/diff):
$pid_ = (bws project list | ConvertFrom-Json)[0].id
Get-Content .env | Where-Object { $_ -match "^\s*[^#].*=" } | ForEach-Object {
$k, $v = $_ -split "=", 2
bws secret create $k.Trim() $v.Trim() $pid_
}
Once you're done registering everything, delete the .env file. That's the whole point of this migration.
Day-to-day usage: bws run
Just prefix any command that needs secrets with bws run --. Every secret in the project gets
injected as an environment variable, keyed by its name, into that process (and its children) only.
bws run -- py scripts/drive_sync.py # Google API を使うスクリプト
bws run -- docker compose up -d db # compose の ${VAR} 展開にも効く
Check or change values either from the Web Vault GUI or the CLI:
bws secret list <プロジェクトID> # 一覧(値も表示されるので注意)
bws secret edit --value "新しい値" <シークレットID>
When you can migrate an existing project without touching the code
bws run only injects environment variables, so as long as your app already reads from the environment, it just works.
- Custom scripts: as long as config loading checks environment variables first and falls back to
.env(which is the standard behavior for most dotenv libraries anyway), it still works. That leaves
an escape hatch for anyone not using Bitwarden — they can keep using.envexactly like before - docker-compose: variable expansion like
${POSTGRES_USER:-default}falls back to
the process's own environment variables when there's no.envfile, sobws run -- docker compose up
just works as-is. Writing a default value for every key also means you can spin things up without Bitwarden at all — handy in a pinch - Secret key names: since these become environment variable names, stick to alphanumerics and
underscores (POSIX constraint). Multibyte values, including Japanese text, go through fine — I tested it
Gotchas (the ones I actually hit)
An environment variable set with setx doesn't show up
setx only takes effect in shells opened after you set it. If you run something in the same window
right after and it says the variable's unset, either reopen your terminal or load it manually into the current shell:
$env:BWS_ACCESS_TOKEN = [Environment]::GetEnvironmentVariable("BWS_ACCESS_TOKEN","User")
Getting 404 Not Found on writes
When bws secret create fails with [404 Not Found] Resource not found., it's usually not
that the secret or project is missing — it's that the machine account lacks write permission
most of the time. Bitwarden returns 404 for permission errors too, to avoid revealing whether the resource exists.
If bws project list works but create returns 404, suspect permissions first.
Quoting issues with bws run on Windows
Because bws run re-runs whatever command it's given through a shell, complex quoting likebws run -- py -c "import os; ..." tends to get mangled partway through and break.
Putting the code in a script file and running bws run -- py script.py instead is far more reliable than a one-liner.
Understand the security limits too
What this setup eliminates is plain-text secret copies sitting on disk, and distribution
depending on one person remembering to do it. That said, some things remain:
- The
BWS_ACCESS_TOKENitself still lives as plain text in a user environment variable (the registry).
An attacker with access to the same machine can ultimately still get at your secrets. The key difference
from.envis that if it leaks, all you have to do is revoke that one token and issue a new one - Credentials that an app writes to disk on its own — like an OAuth token cache
(token_drive.json, say) — are a separate matter and still stick around bws runhits the API every time it runs, so it won't work offline.
If you need to work offline, generate a temporary.envfor the session and delete it once you're done
The official best practices also recommend going further: split machine accounts
by use case (person, agent, CI), and set expiration dates on tokens meant for short-lived use.
Wrap-up
- The role
.envused to play can be replaced by a secrets management service plus runtime injection - With Bitwarden Secrets Manager, prefixing a command with
bws run --is basically all it takes —
almost no code changes, and distributing access to the team comes down to handing out a single token - The actual migration work is three clicks in the Web Vault, unzipping the CLI, and running
setxonce
References
- Secrets Manager CLI | Bitwarden — how
bws runworks, and its authentication methods - Developer Quick Start | Bitwarden — recommended steps for a dev workflow
- Your coding agent can read your .env file | Bitwarden Blog — the reasoning behind moving away from .env, and best practices
- bitwarden/sdk-sm Releases — binary releases of the bws CLI
