· Agents · 5 min read
Making Bitwarden non-interactive for an AI agent
The problem in one sentence
I want Claude Code to do bulk edits in my Bitwarden vault — rotate a batch of lab VM passwords, reconcile a Firefox CSV against the vault, that kind of thing — and every Bitwarden CLI is built around the assumption that a human is at the keyboard. Master password? Prompt. API client secret? Prompt. 2FA code? Prompt. An agent can't answer those, and routing each one through chat turns a two-minute job into a fifteen-minute back-and-forth.
So the real goal is narrower than it sounds: make the CLI completely silent, with credentials coming from a vault I already have (OpenBao) and the agent only ever needing the human for the one ~30-second-lived TOTP code that can't be cached.
This post is the recipe for getting there. The headline trick is at the bottom; the failed paths are above it to save anyone else the search.
The dead end: bw 2026.4.1 plus shell env vars
The official Bitwarden CLI accepts BW_CLIENTID / BW_CLIENTSECRET / BW_PASSWORD from the environment, which on paper is everything you'd want for non-interactive use. In practice:
$ BW_CLIENTID=... BW_CLIENTSECRET=... bw login --apikey
You are logged in!
$ bw unlock "$MASTER_PW" --raw
zDq3iWdfMri4OIhDi1u6srJvnsrxZEsnsfwA/6whukTbG+Fc... ← looks fine
$ bw list items --session "$THAT_SAME_TOKEN" --nointeraction
Vault is locked.
Login works. Unlock prints an 88-character base64 session that looks exactly like a valid key. Every subsequent op — list, get, export — rejects that same session with Vault is locked. Same result through BW_SESSION, through --session, through --passwordenv, with TTY and without. I never pinned down the root cause; this might be an Argon2/PBKDF2 quirk specific to my account, or a 2026.4.x regression. Either way, the official CLI was a dead end. Moving on.
The almost-good answer: rbw
rbw is the unofficial Rust client. It's small, fast, ships with an agent that caches the unlock so you only enter the master password once per timeout window. Promising. Then:
$ rbw login
rbw login: unsupported two factor methods: [WebAuthn]
rbw doesn't support WebAuthn 2FA. My account had exactly one 2FA method — a YubiKey via WebAuthn. So step one was actually a Bitwarden web-UI click-trail: add an authenticator-app (TOTP) method as a second 2FA option. WebAuthn stays available for browser logins; rbw uses TOTP.
Try login again:
$ rbw login
rbw login: failed to log in to bitwarden instance: failed to parse JSON: EOF
That's a 429 in disguise. Bitwarden has bot-detection on /identity/connect/token that returns an empty body once you've failed enough times. rbw register --help tells you exactly how to get past it:
The official Bitwarden server includes bot detection to prevent brute force attacks. In order to avoid being detected as bot traffic, you will need to use this command to log in with your personal API key (instead of your password) first before regular logins will work.
So the canonical flow is rbw register (one-time, with the personal API key, no 2FA prompt) → rbw login (master pw + TOTP) → rbw unlock (master pw only, every session thereafter).
The actual insight: rbw asks for everything through pinentry
This is the part worth a blog post. rbw register doesn't take --client-id/--client-secret flags. It doesn't read BW_CLIENTID. If you pipe credentials to its stdin they're ignored. It prompts. Through pinentry.
pinentry is the GnuPG-flavored credential prompt — a tiny program that speaks a line-oriented protocol called Assuan over stdin/stdout. Real pinentry programs pop a TTY/curses/GUI dialog. Nothing about the protocol requires a human at the other end. You can write a pinentry that fetches the answer from anywhere.
That's the entire fix. One shim that pretends to be pinentry, reads the prompt text rbw is sending, and pulls the matching secret from OpenBao.
#!/usr/bin/env bash
# ~/.local/bin/rbw-pinentry-bao
set -euo pipefail
echo "OK Pleased to meet you"
DESC=""; PROMPT=""
while IFS= read -r line; do
case "$line" in
SETDESC*) DESC="${line#SETDESC }"; echo "OK" ;;
SETPROMPT*) PROMPT="${line#SETPROMPT }"; echo "OK" ;;
GETPIN*)
ask="${DESC} ${PROMPT}"
shopt -s nocasematch
if [[ $ask =~ (authenticator|verification[_\ ]*code) ]]; then
# TOTP. One-shot file the orchestrator writes just before invoking rbw.
secret=$(cat /tmp/rbw_totp); rm -f /tmp/rbw_totp
elif [[ $ask =~ client[_\ ]*id ]]; then
secret=$(bao kv get -field=client_id kv/bitwarden/main)
elif [[ $ask =~ (client[_\ ]*secret|api[_\ ]*key) ]]; then
secret=$(bao kv get -field=client_secret kv/bitwarden/main)
else
secret=$(bao kv get -field=pass kv/bitwarden/main) # master pw
fi
shopt -u nocasematch
printf 'D %s\n' "$secret"; echo "OK" ;;
BYE*) echo "OK closing connection"; exit 0 ;;
*) echo "OK" ;;
esac
done
Point rbw at it once:
rbw config set pinentry rbw-pinentry-bao
…and rbw register, rbw login, rbw unlock are all silent forever after. The orchestrator never types a secret; the shim does it from OpenBao based on which prompt rbw is showing.
Specific things to watch for, all of which bit me:
- Order your regex specific-to-general.
rbw registersendsSETPROMPT API key client__idandSETPROMPT API key client__secret. My first attempt's(client_secret|api key)branch matched both because both containAPI key. Server said "incorrect api key" with a straight face. Matchclient_idfirst, fall through to the rest. - Don't log secret values. The shim above logs only metadata if you add
echo "$ask" >> /tmp/pinentry.logfor debugging — never theD <secret>line. A leaky pinentry is worse than no pinentry. - Always check
SETDESCandSETPROMPT. They carry different text. Concatenating both gives the matcher more to work with.
Handling the one secret you can't cache: TOTP
A TOTP code is alive for 30 seconds. It can't sit in OpenBao. The shim reads it from /tmp/rbw_totp — a single-use file — and the orchestrator writes it there immediately before the rbw login:
# Asked the human for a fresh 6-digit code in chat.
printf '%s' "$CODE" > /tmp/rbw_totp
rbw login
rbw login triggers the pinentry once for the master password (shim → OpenBao) and once for the TOTP (shim → temp file, file deleted). After that, the device is registered with Bitwarden's server. rbw unlock thereafter is master-pw-only and runs entirely off the shim. TOTP is only needed again after rbw purge or if Bitwarden drops the device record.
The recipe
Stripping it back to a checklist for anyone repeating this:
- Add an authenticator-app (TOTP) 2FA method in the Bitwarden web UI. WebAuthn alone isn't enough; rbw doesn't speak it.
- Store all four secrets in a vault you control (OpenBao, Vault, a password manager, whatever):
client_id,client_secret,pass(master password), and a fixed temp-file path for TOTP delivery. - Write a pinentry shim that reads
SETPROMPT/SETDESCfrom rbw and returns the matching secret. Order regexes specific-first. Log metadata only. rbw config set pinentry <your-shim>so rbw uses it.rbw registeronce — bypasses bot detection using the API key (shim feeds it).rbw loginonce per device — needs a fresh TOTP, asked once.rbw unlockevery session — fully silent, agent runs it like any other command.
What that buys you: an agent can call rbw unlock && rbw list or rbw add mid-task with zero human-in-the-loop except for the very first TOTP. The original goal — bulk operations on a Bitwarden vault, driven by an agent that doesn't have hands — is unblocked.
What this is and isn't
This isn't a way to bypass Bitwarden's security model. The master password and API key still authenticate the same way; I've just relocated where they live from "in a human's head, typed into a prompt" to "in another vault I trust, fetched by a script." The threat model swaps "shoulder-surfing a master password prompt" for "compromise of the OpenBao token in my environment" — that trade is fine for my use case but you should think about whether it's fine for yours.
It also isn't a generic Bitwarden lesson, in the sense that everything specific to bw (the official CLI bug, the env var quirks) is a sideshow. The actual transferable idea is that any tool which prompts through pinentry — gpg, pass, rbw, others — is one shim away from being scriptable, and the shim can be aware enough of which prompt is showing to return different secrets for different questions.