Homelab Haven

Bitwarden Secrets Manager for AI Agents

September 14, 20268 min read

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 bws to ~/.local/bin, no sudo. Store the token in a dedicated file, not ~/.bashrc.
  • bws only 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:

  1. Enable Secrets Manager in the org settings. It's a separate feature from the password vault, check it's available on your plan.
  2. 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.
  3. 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.
  4. 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 unlockBW_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_TOKEN and op read, op inject and op run work non-interactively from there. Same file-not-.bashrc advice 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 bws token: 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

If this saved you some time, a coffee keeps the lights on and the posts coming.

☕ Buy me a coffee

Comments

Comments are checked before they appear.