I have already write a post about bitwarden backup, about this very topic. At first, I thought to just update it as I did with cloudflare backup and later with faktury-online backup. After a bit of changes to that post I have found out that I have made so much changes to the whole idea and the script itself that a new post is worth writing instead.
GitHub Action workflow #
Apart from setting up the secrets, this is the only script that is needed
for me. Works for both personal and org accounts without a problem. Save as
.github/workflows/main.yml and as always, activate GitHub workflows write
permissions so the commits can me made periodically.
name: Bitwarden.com backup
on:
workflow_dispatch:
schedule:
- cron: "30 0 * * 0"
jobs:
backup:
runs-on: ubuntu-22.04
env:
BW_CLIENTID: ${{ secrets.BW_CLIENTID }}
BW_CLIENTSECRET: ${{ secrets.BW_CLIENTSECRET }}
BW_PASSWORD: ${{ secrets.BW_PASSWORD }}
BW_ORGANIZATION_ID: ${{ secrets.BW_ORGANIZATION_ID }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: |
npm install
./node_modules/@bitwarden/cli/build/bw.js login --apikey
./node_modules/@bitwarden/cli/build/bw.js export --format json --session $(./node_modules/@bitwarden/cli/build/bw.js unlock --passwordenv BW_PASSWORD --raw) --raw | openssl aes-256-cbc -a -salt -pbkdf2 -k $BW_PASSWORD -out personal.json.enc
./node_modules/@bitwarden/cli/build/bw.js export --format json --session $(./node_modules/@bitwarden/cli/build/bw.js unlock --passwordenv BW_PASSWORD --raw) --raw --organizationid "$BW_ORGANIZATION_ID" | openssl aes-256-cbc -a -salt -pbkdf2 -k "$BW_PASSWORD" -out organization.json.enc
./node_modules/@bitwarden/cli/build/bw.js lock
./node_modules/@bitwarden/cli/build/bw.js logout
- if: ${{ !env.ACT }}
uses: stefanzweifel/git-auto-commit-action@v5
Running locally #
It is possible to run this locally via act but the guide is already in
previous posts, so I will just state here what is strictly required:
npm i @bitwarden/cli
npx bw login
Decrypting #
The backup is stored in an encrypted form even in a repository. The password is stored in GitHub secrets and when your account is compromised it of course can be extracted, but hey, you should have your 2FA stored in different location/device anyway. To decrypt the data to actually use it run the following:
openssl aes-256-cbc -d -a -pbkdf2 -in personal.json.enc -out personal.json
openssl aes-256-cbc -d -a -pbkdf2 -in organization.json.enc -out organization.json
Enjoy!
Update 2026-04-09 #
The organization backup was silently broken for a while and I did not
notice. The BW_ORGANIZATION_ID secret had an outdated value — Bitwarden
had reassigned the organization ID at some point. The CLI did not complain
at all. It exported the vault, found no items matching the ID, and produced
this completely valid but completely empty JSON:
{
"encrypted": false,
"collections": [
{
"id": "e101ecd7-10c8-438f-a6bd-aed801304538",
"organizationId": "c987eb3d-59b7-4ef9-aad4-aed801304514",
"name": "Default Collection",
"externalId": null
}
],
"items": []
}
OpenSSL encrypted it, the workflow committed it, everything looked fine. I only noticed when I decrypted the file out of curiosity and found no items in there whatsoever.
A more precise fix would be to inspect the JSON with jq before encrypting
— check that items | length > 0 and that organizationId in the response
matches $BW_ORGANIZATION_ID. The problem is doing that cleanly. The
export currently pipes straight into openssl, so inspecting the content
would mean either writing the unencrypted JSON to disk first (not great) or
running the export twice — once to validate, once to encrypt (wasteful and
inconsistent if something changes between calls). Neither felt worth it.
Instead, a size check after the fact turned out to be good enough. The encrypted empty JSON produced by a wrong org ID comes out to around 370 bytes. An encrypted file with any real vault data is going to be well over 500 bytes. If either file is suspiciously small, the workflow now fires a GitHub Actions warning and fails the step:
- name: Validate backup sizes
id: validate
continue-on-error: true
run: |
MIN_SIZE=500
for file in personal.json.enc organization.json.enc; do
size=$(wc -c < "$file")
if [ "$size" -lt "$MIN_SIZE" ]; then
echo "$file is too small: $size bytes (minimum $MIN_SIZE)"
exit 1
fi
done
- name: Notify on backup too small
if: steps.validate.outcome == 'failure'
run: |
echo "::warning::Backup validation failed - one or more encrypted files are suspiciously small"
exit 1
continue-on-error: true on the validate step means the commit still
happens even on failure — the bad backup gets stored so you can inspect it.
The notification step then fails the workflow run, which shows up as a
failed action in GitHub and sends an email. Hard to miss.
The correct organization ID can be found by decrypting a recent backup and
reading the organizationId field from any collection entry, as shown
above.