Tutorial · CLI · 2,200 words

Useful Bitwarden CLI scripts — backup, rotate, audit

Terminal window with green text on a dark background — automating the Bitwarden CLI for backups, rotation and audits

The bw CLI is the underrated half of Bitwarden. The web vault, desktop app, and browser extension cover daily use, but anything you want to automate — nightly backups, password rotation across services, weak-password audits, secrets injection into containers — happens through the CLI or it doesn't happen at all. This post is a small library of production-grade scripts I run on my own vault, with the gotchas that took me a few iterations to figure out.

Everything below is bash. Translate to PowerShell or Python where needed; the patterns transfer cleanly.

Important

The CLI puts plaintext secrets in environment variables and shell pipelines. Treat any host you run these scripts on as a high-trust environment. Don't copy-paste these onto a shared dev box. Don't commit the BW_SESSION value to git. Run as a dedicated user with restrictive permissions on the output files.

Why script the CLI at all

Three categories of work the CLI is good for:

Routine backups. The web vault\'s manual export is fine for monthly hygiene but it\'s not something you\'ll remember to do nightly. A cron job that runs bw export every night and lands a fresh encrypted file in your backup destination — no decision-making, no skipping — is the right pattern.

Cross-service operations. Need to rotate the database password on 12 services after a breach? Doing it through the web vault means hand-editing 12 items, then logging into 12 services, then copy-pasting 12 new passwords. Doing it through the CLI is a script that generates passwords, updates the vault, and pipes the new passwords to whatever service-specific tool needs them.

Secrets injection. Docker containers, CI runners, ad-hoc scripts — anywhere you\'d otherwise have a .env file with plaintext secrets, the CLI lets you fetch from the vault at runtime and never write the secret to disk.

Install & auth

The CLI ships as a single static binary. On most systems:

# macOS / Linux via npm (works on Windows too)
npm install -g @bitwarden/cli

# macOS via Homebrew
brew install bitwarden-cli

# Linux via Snap
snap install bw

# Windows via Scoop
scoop install bitwarden-cli

# Verify
bw --version

Login is a one-time step:

bw login [email protected]
# Prompts for master password and 2FA code if you have 2FA enabled

You\'re now logged in but the vault is locked. Each script needs to unlock to read or write. bw unlock returns a session token you pass to subsequent commands.

Session handling done right

This is the most-fumbled part of CLI scripting. The wrong way:

# DON\'T DO THIS
bw unlock --raw > /tmp/bw_session
export BW_SESSION=$(cat /tmp/bw_session)
# session token now sitting in a file on disk that may stay there indefinitely

The right way: capture the session into a shell variable, use it, lock when done, never let it touch disk. The pattern I use:

#!/usr/bin/env bash
set -euo pipefail

# Source master password from a secrets manager you trust
# Examples: pass, gopass, op (1Password CLI), AWS Secrets Manager
BW_PASSWORD=$(pass show bw/master)

# Unlock and capture session in-process only
export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)

# ...do work that uses bw commands...

# Always lock at the end (also runs on script exit via trap)
trap 'bw lock > /dev/null' EXIT

The --passwordenv flag reads the master from an environment variable rather than prompting interactively, so cron jobs work. The session lives only in the script\'s process memory; killing the script clears it. The trap on EXIT ensures even an error path locks the vault.

Nightly encrypted backup

The full script I run on my home server every night at 2am:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR=/var/backups/bitwarden
RETENTION_DAYS=90
BW_PASSWORD=$(pass show bw/master)
EXPORT_PWD=$(pass show bw/export-password)

mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"

# Sync first — make sure we\'re backing up the latest
bw sync > /dev/null

export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
trap 'bw lock > /dev/null; unset BW_SESSION' EXIT

OUT="$BACKUP_DIR/bw-$(date +%Y%m%d).json"

bw export \
  --format encrypted_json \
  --password "$EXPORT_PWD" \
  --output "$OUT"

chmod 600 "$OUT"

# Prune old backups
find "$BACKUP_DIR" -name 'bw-*.json' -mtime "+$RETENTION_DAYS" -delete

# Off-site copy via restic
restic -r s3:s3.amazonaws.com/your-bucket backup "$OUT" \
  --tag bitwarden > /dev/null

echo "Backup complete: $OUT"

Three things this script gets right that most online examples miss: it locks the vault on every exit path (not just success), it prunes old backups so disk fills up at a known rate, and it pushes off-site immediately so a single-host failure doesn\'t cost a single backup.

Test the restore monthly. Point a fresh Bitwarden account at one of these encrypted files, import it, verify your items appear. A backup you\'ve never restored is hope, not a backup.

Backing up attachments separately

The export references attachments by ID but doesn\'t include the binary content. For a complete backup, dump them separately:

#!/usr/bin/env bash
set -euo pipefail

ATTACH_DIR=/var/backups/bitwarden/attachments-$(date +%Y%m%d)
BW_PASSWORD=$(pass show bw/master)

bw sync > /dev/null
export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
trap 'bw lock > /dev/null' EXIT

mkdir -p "$ATTACH_DIR"
chmod 700 "$ATTACH_DIR"

# For each item with attachments, download each attachment
bw list items 2>/dev/null | jq -r '.[] | select(.attachments != null) | .id' | while read -r ITEM_ID; do
  ITEM_DIR="$ATTACH_DIR/$ITEM_ID"
  mkdir -p "$ITEM_DIR"

  bw get item "$ITEM_ID" 2>/dev/null | jq -c '.attachments[]' | while read -r ATT; do
    ATT_ID=$(echo "$ATT" | jq -r '.id')
    FILENAME=$(echo "$ATT" | jq -r '.fileName')
    bw get attachment "$ATT_ID" --itemid "$ITEM_ID" --output "$ITEM_DIR/$FILENAME" > /dev/null
  done
done

tar czf "$ATTACH_DIR.tar.gz" -C "$(dirname "$ATTACH_DIR")" "$(basename "$ATTACH_DIR")"
rm -rf "$ATTACH_DIR"
chmod 600 "$ATTACH_DIR.tar.gz"

echo "Attachments backed up: $ATTACH_DIR.tar.gz"

Then push that tarball alongside the encrypted JSON. Together they\'re the complete vault state.

Password rotation pipeline

Scenario: you want to rotate the password for a specific item, generate a new strong one, push it to the live service via that service\'s API, and update the vault. End-to-end script for one item:

#!/usr/bin/env bash
set -euo pipefail

ITEM_NAME="$1"  # e.g. "AWS Console - Personal"
BW_PASSWORD=$(pass show bw/master)

bw sync > /dev/null
export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
trap 'bw lock > /dev/null' EXIT

# Generate new password
NEW_PWD=$(bw generate --length 32 --uppercase --lowercase --number --special)

# Find the item
ITEM_ID=$(bw list items --search "$ITEM_NAME" 2>/dev/null | jq -r '.[0].id')
if [[ -z "$ITEM_ID" || "$ITEM_ID" == "null" ]]; then
  echo "Item not found: $ITEM_NAME" >&2
  exit 1
fi

# Push to the upstream service first (example: AWS IAM)
# aws iam update-login-profile --user-name myuser --password "$NEW_PWD" --password-reset-required false

# Update the vault item
ITEM_JSON=$(bw get item "$ITEM_ID" 2>/dev/null)
NEW_ITEM=$(echo "$ITEM_JSON" | jq --arg p "$NEW_PWD" '.login.password = $p')
echo "$NEW_ITEM" | bw encode | bw edit item "$ITEM_ID" > /dev/null

bw sync > /dev/null
echo "Rotated $ITEM_NAME"

The pattern is: generate, push to upstream, update vault, in that order. If the upstream push fails, the vault still has the old password and you can retry. Updating the vault first risks a silent mismatch where the vault has a password the actual service doesn\'t know.

Weak / reused password audit

A simple script that flags items with reused passwords across your vault. Useful for periodic vault hygiene:

#!/usr/bin/env bash
set -euo pipefail

BW_PASSWORD=$(pass show bw/master)

bw sync > /dev/null
export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
trap 'bw lock > /dev/null' EXIT

# Group items by password hash and surface duplicates
bw list items 2>/dev/null | \
  jq -r '.[] | select(.login.password != null) | "\(.login.password)\t\(.name)"' | \
  awk -F'\t' '{ counts[$1]++; names[$1] = names[$1] "; " $2 } END {
    for (p in counts) if (counts[p] > 1) print counts[p] "x reused:" names[p]
  }' | sort -rn

Output looks like 3x reused: Netflix; Disney+; Spotify — items that share a password. Premium users can run Bitwarden\'s built-in vault health report through the web vault for the same data plus weak-password and breach checks; the CLI version is for scripting it into a periodic email or Slack notification.

Injecting secrets into Docker or CI without a .env

Standard pattern: instead of docker run --env-file .env with a static file on disk, fetch from Bitwarden at run-time:

#!/usr/bin/env bash
set -euo pipefail
BW_PASSWORD=$(pass show bw/master)

export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
trap 'bw lock > /dev/null' EXIT

# Fetch each value, run docker with --env (not --env-file)
DB_PASSWORD=$(bw get password "Production DB - PostgreSQL")
REDIS_PASSWORD=$(bw get password "Production Redis")
S3_KEY=$(bw get password "AWS Access Key - prod")
S3_SECRET=$(bw get password "AWS Secret Key - prod")

docker run -d \
  --name myapp \
  --env DB_PASSWORD="$DB_PASSWORD" \
  --env REDIS_PASSWORD="$REDIS_PASSWORD" \
  --env S3_ACCESS_KEY="$S3_KEY" \
  --env S3_SECRET_KEY="$S3_SECRET" \
  myapp:latest

The secrets exist in this script\'s memory and the container\'s environment, but never touch disk on the host. Combined with running the launcher as a dedicated user and restricting /proc/<pid>/environ via host hardening, this is meaningfully better than a checked-in .env.production.

For Kubernetes, the same pattern works wrapping kubectl create secret generic ... --from-literal calls — useful for first-time secret deployment. For long-lived rotation, a CSI driver or operator (External Secrets Operator) tied to a real secrets manager is the more sustainable answer; the Bitwarden CLI is the right hammer for ad-hoc and small-team work.

Wiring it into cron / systemd timers

The crontab entry for the nightly backup:

0 2 * * * /home/bw/backup.sh >> /var/log/bw-backup.log 2>&1

Two things to get right:

For systemd users, a oneshot service plus a timer is cleaner than cron. The same script body, with systemctl handling logging via journald and failure notifications via OnFailure handlers.

Common mistakes

  1. Storing BW_SESSION in a file. Defeats the encryption-at-rest property. Always hold it in a process variable that disappears when the script exits.
  2. Logging secrets. bw get password output piped to set -x bash mode will print the password to logs. Disable -x around secret-reading commands or use { +x; cmd; -x; } blocks.
  3. Skipping bw sync. Without an explicit sync, the CLI uses cached data that may be hours old. Always sync at the start of any read or write operation.
  4. Not locking on error paths. Use trap ... EXIT so the vault locks even if the script crashes.
  5. Leaving the master password in shell history. Use --passwordenv with the password in an environment variable sourced from a secrets manager, never --password <value> as an arg.
  6. Hard-coding item IDs. Item IDs change if you re-import the vault. Use bw list items --search to look up by name instead, with error handling for "found multiple" / "found none."