Bitwarden Secrets Manager for AI Agents
In Part 8 of the media stack series I gave Claude API-key access to Sonarr and Radarr through MCP. That works fine when the key lives in an MCP config file you wrote by hand. It stops working the moment an agent needs to fetch a credential itself, for a task you didn't set up in advance, without you pasting it into the chat window every time.
That's the problem Bitwarden Secrets Manager solves. It's a separate product from the regular password vault, on the same Bitwarden account, built specifically for machine access.
Summary
- The password vault (
bw) needs an interactive master-password unlock every session. That can't happen at all in some agent contexts, no terminal, no human present. - Secrets Manager (
bws) uses a long-lived access token on a machine account instead. No unlock, no human required to read a secret. - Setup: enable Secrets Manager on the org, create a project, create a machine account scoped to that project, generate a token.
- Install
bwsto~/.local/bin, no sudo. Store the token in a dedicated file, not~/.bashrc. bwsonly looks up secrets by UUID, never by name, so you keep your own name-to-ID table.- Claude's own permission classifier blocks bulk reads. Fetch one secret at a time.
Why not just keep using the password vault
bw unlock needs a master password typed into an interactive shell. An agent's shell
invocations are usually non-interactive, so there's often no terminal for a human to
unlock anything in, and even when there is, re-unlocking every session gets old fast.
Secrets Manager trades that for a different model. Instead of "decryption requires a human who knows the master password, every time," it's "decryption requires possessing the access token." Whoever or whatever holds that token gets plaintext on request, no further gate. Both products encrypt at rest identically; what changes is what stands between a credential holder and the plaintext.
That's a real trade-off, not a strict upgrade. I only migrate a credential into Secrets Manager if I'm fine with "holding the token is enough" for that specific secret. An API token for a service I'd rotate anyway clears that bar easily. Something like an SSH private key deserves a harder look before it goes in.
Setting up Bitwarden Secrets Manager
One-time, per Bitwarden organization:
- Enable Secrets Manager in the org settings. It's a separate feature from the password vault, check it's available on your plan.
- Create a project to hold a repo or domain's secrets. I used
homelab. Projects are the access-control boundary: a machine account gets granted to specific projects, not to the whole org. - Create a machine account scoped to that project. Start with read/write while migrating existing secrets in. If the agent using it never needs to create or rotate secrets itself, drop it to read-only afterwards.
- Generate an access token for the machine account. It's shown once, so copy it immediately.
Installing bws with no sudo
Releases are per-architecture zips on GitHub. Check uname -m first: picking the wrong
architecture fails silently at first run, not at download time.
ARCH=$(uname -m) # aarch64 or x86_64
VERSION="2.1.0" # check https://github.com/bitwarden/sdk-sm/releases for the latest bws-v* tag
mkdir -p ~/.local/bin
cd /tmp
curl -sL -o bws.zip \
"https://github.com/bitwarden/sdk-sm/releases/download/bws-v${VERSION}/bws-${ARCH}-unknown-linux-gnu-${VERSION}.zip"
unzip -o bws.zip -d bws_extract
mv bws_extract/bws ~/.local/bin/bws
chmod +x ~/.local/bin/bws
rm -rf bws.zip bws_extract
~/.local/bin/bws --version
No sudo needed, it installs to a user-writable path. If your environment doesn't have
passwordless sudo (mine doesn't), don't fight it: ~/.local/bin works fine as long as
it's on PATH or you invoke it by full path.
Don't put the token in ~/.bashrc
This one is easy to burn time on, because the symptom looks exactly like a broken
token. ~/.bashrc only loads for interactive shells:
most versions of the file have a guard near the top
(case $- in *i*) ;; *) return;; esac or similar) that returns immediately otherwise.
An agent's shell invocations are non-interactive, so a token exported in .bashrc is
invisible to every single one of them. It looks exactly like a broken token, and it's
the same failure shape as Bitwarden's own BW_SESSION env var problem with bw.
Use a dedicated file with no interactive guard instead, sourced explicitly before every
bws call:
printf 'export BWS_ACCESS_TOKEN=%s\n' "<token>" > ~/.bws_env
chmod 600 ~/.bws_env
Every command that needs it becomes source ~/.bws_env && bws .... This turns out to
be more reliable than an inherited environment variable anyway: a sub-agent's shell
doesn't inherit the parent process's environment, but it can run the exact same
source ~/.bws_env && ... command and get the same result every time.
Fetching a secret with bws
bws has no lookup by name, only by UUID. That means you keep your own name-to-ID
table somewhere in the repo's own secrets doc, written down when you create each
secret.
source ~/.bws_env
bws secret get <secret-id> # prints the full record, including plaintext value
I prefer piping the value straight into whatever consumes it over printing it on its own:
source ~/.bws_env
TOKEN=$(bws secret get <secret-id> -o json | python3 -c "import json,sys; print(json.load(sys.stdin)['value'])")
curl -H "Authorization: Bearer $TOKEN" https://example.internal/api/...
That way the plaintext value only exists in a shell variable for the duration of one command, not sitting in scrollback.
War story one: bulk reads get blocked, and that's expected
The first time I tried to "verify everything's migrated correctly" by reading several secrets back-to-back, Claude Code's permission classifier refused the sequence. A single, isolated read generally passes. A sequence that looks like bulk credential harvesting doesn't, even when it's read-only and even when I know exactly why I'm doing it.
This is the same behavior already documented for bw list items on the password-vault
side, so I shouldn't have been surprised. The fix isn't to fight the classifier, it's
to change the pattern: fetch one secret at a time, for an actual task that needs it, not
for a standing audit.
When I do need a name-to-ID mapping without exposing any values, filtering in the command itself avoids tripping the classifier at all, because plaintext values never appear in the output:
source ~/.bws_env
bws secret list <project-id> -o json | python3 -c "
import json, sys
for s in json.load(sys.stdin):
print(f\"{s['key']} = {s['id']}\")"
War story two: migrating an existing vault item, one at a time
Moving a credential from the password vault into Secrets Manager needs the vault
unlocked first (bw unlock → BW_SESSION, stored the same non-.bashrc way, in a
dedicated file like ~/.bw_env, for the identical interactive-shell reason). Bulk bw
reads and edits are classifier-blocked too, so this is also one item at a time:
source ~/.bw_env; source ~/.bws_env
PROJECT_ID="<project-id>"
name="some-item-name"
pw=$(bw get password "$name" --session "$BW_SESSION")
bws secret create "$name" "$pw" "$PROJECT_ID" --note "migrated from Bitwarden password vault $(date +%F)" -o none
-o none suppresses output entirely. It avoids re-printing the value I just wrote, and
in practice it didn't trigger the classifier the way a read-back call did.
Secure-note items, like an SSH private key, have no password field: use
bw get notes "$name" --session "$BW_SESSION" instead. Private-key-shaped content is
more likely to get classifier-blocked than a plain API token. If that happens, I don't
work around it: I either paste it into the Secrets Manager UI by hand, or ask for an
explicit permission rule.
If you're not on Bitwarden
Everything above is Bitwarden because that's the only secrets manager I've actually run this way. I haven't tested the others, so treat this section as a map, not a walkthrough.
The pattern itself isn't Bitwarden-specific, though. What made it work for an agent was the shape, not the vendor: a machine credential instead of a human unlock, that credential stored in a file with no interactive guard and sourced per command, the secret value piped straight into whatever consumes it, and one read per actual task. I checked the docs of the password managers most people already have, to see which of them offer that shape:
- 1Password does, through service accounts:
you put the token in
OP_SERVICE_ACCOUNT_TOKENandop read,op injectandop runwork non-interactively from there. Same file-not-.bashrcadvice applies to that variable. One limit: a service account can't reach your personal vault, only shared ones you scope it to. - Proton Pass is the closest match of the four. Its CLI has
personal access tokens for headless
use, plus separate access tokens made for AI agents with their own logging. The one
real difference from a
bwstoken: a Proton token has a mandatory expiry you set at creation (from an hour up to a year) and each session lasts two hours, so an agent that runs unattended for months needs a renewal step built in. - NordPass has no equivalent I could find. The tokens it offers are business-plan admin tokens for its activity-log API and SIEM integrations, not for reading vault items from a script.
- LastPass is the model this post moved away from. Its CLI logs in with the master password and documents no non-interactive login, so it works with a human present but not for an unattended agent.
The setup steps and exact commands differ per tool, and the same trade-off applies to every one of them: whoever holds the machine credential gets the plaintext, so decide per secret whether that's acceptable before you migrate it.
What I'd do differently
I kept the old password-vault items in place as a fallback rather than deleting them
the moment the migration script ran, and I'd do that again: the new retrieval path
needs to prove itself in real use first. I also record the name-to-ID table (and the
project ID) in the repo's own secrets doc as I go, since there's no way to ask bws
"what was that one called" after the fact.
One thing I haven't touched: credentials used directly by CI or deploy pipelines, not by an agent. This migration changes how agents read secrets. It doesn't change how a deployed service gets its own.
Related
Dockhand Multi-Host Dashboard
Why I run Dockhand across my Docker hosts, how the Gitea-first deploy flow works, and the API that lets an agent manage containers without SSH.
Docker Compose Basics
The five ideas behind every compose.yaml on this site: images vs containers, volumes, networks, restart policies, and PUID/PGID conventions.
AI + Recipe Manager
Inconsistent tags, vague titles, pasted-text ingredients: using ChatGPT or Claude to fix a self-hosted recipe collection, with copy-paste prompts.
If this saved you some time, a coffee keeps the lights on and the posts coming.
☕ Buy me a coffee