Skip to main content

bitwarden

Salt Extension Modules for Bitwarden

Introduction

This extension for Salt enables Salt to access and administer a Bitwarden vault/instance.

This project aims to eventually have 100% coverage of the Bitwarden Vault Management API and the Bitwarden Public (Organization Management) API. The Vault Management API is now covered for both reads and writes; the Public API is not yet implemented.

This extension is changing quickly, and while attempts will be made to not make breaking changes, the current focus is on functionality rather than syntax/API stability.

Requirements

This extension requires the bw Bitwarden CLI utility to be installed for functions that depend on the Bitwarden Vault Management API. Installation instructions are available here. It also has dependencies on the pyhumps and requests python packages, which are automatically installed.

Python 3.10 or later and Salt 3006 or later are required.

Talking to bw serve over a unix socket, which is the recommended deployment (see Connecting to the API), requires Bitwarden CLI 2025.9.0 or later. Earlier releases can only listen on a TCP port.

Installation

An example state file for installing and configuring this extension on EL8 (RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Scientific Linux, etc.):

# Install the NodeSource EL8 repo to get Node.js
cmd_nodesource-el8.repo:
  cmd.run:
    - name: curl -fsSL https://rpm.nodesource.com/setup_18.x | bash -
    - creates:
        - /etc/yum.repos.d/nodesource-el8.repo

# Bitwarden CLI depends on Node.js
pkg_nodejs:
  pkg.installed:
    - name: nodejs
    - require:
        - cmd_nodesource-el8.repo

# Install the Bitwarden CLI client (which includes the Vault Management REST API
# server)
npm_bitwarden_cli:
  npm.installed:
    - name: '@bitwarden/cli'
    - require:
        - pkg_nodejs

# Install the Bitwarden Salt Extension
pip_saltext.bitwarden:
  pip.installed:
    - name: saltext.bitwarden
    - require:
        - npm_bitwarden_cli

# Configure server options for Bitwarden REST API Server
file_/etc/sysconfig/bw-api:
  file.managed:
    - name: /etc/sysconfig/bw-api
    - user: root
    - group: root
    - mode: "0644"
    - contents: |
        # Command-line options for bw serve
        BITWARDENCLI_APPDATA_DIR=/etc/salt/.bitwarden
        OPTIONS="--hostname unix:///run/bw-api/vault.sock"
    - require:
        - pip_saltext.bitwarden
    - watch_in:
        - service_bw-api

# Create a systemd service unit file since one is not included in the package.
# Reloads systemctl if the file changes.
file_/etc/systemd/system/bw-api.service:
  file.managed:
    - name: /etc/systemd/system/bw-api.service
    - user: root
    - group: root
    - mode: "0644"
    - contents: |
        [Unit]
        Description=Bitwarden Vault Management API
        Documentation=https://bitwarden.com/help/cli/
        After=network.target

        [Service]
        EnvironmentFile=-/etc/sysconfig/bw-api
        User=salt
        Group=salt
        Type=simple
        WorkingDirectory=~
        # Creates /run/bw-api owned by salt:salt, mode 0750, and removes it on
        # stop. Traversing that directory is what gates access to the socket,
        # so only root and the salt group can reach the API.
        RuntimeDirectory=bw-api
        RuntimeDirectoryMode=0750
        ExecStart=/bin/bw serve $OPTIONS
        Restart=always

        [Install]
        WantedBy=multi-user.target
    - require:
        - file_/etc/sysconfig/bw-api
    - watch_in:
        - service_bw-api

# You must manually create files in /etc/salt/master.d/bitwarden.conf or
# /etc/salt/minion.d/bitwarden.conf (or both) depending on the context, with the following contents
# (substituting values as appropriate):
#
#  bitwarden:
#    driver: bitwarden
#    cli_path: /bin/bw
#    cli_conf_dir: /etc/salt/.bitwarden
#    cli_runas: salt
#    vault_url: https://bitwarden.com
#    email: user@example.com
#    password: CorrectHorseBatteryStaple
#    vault_api_url: unix:///run/bw-api/vault.sock
#    public_api_url: https://api.bitwarden.com
#    client_id: 25fa6fc6-deeb-4b42-a279-5e680b51aa58
#    client_secret: AofieD0oexiex1mie3eigi9oojooF3
#    org_client_id: organization.d0e19db4-38aa-4284-be3d-e80cff306e6c
#    org_client_secret: aWMk2MBf4NWXfaevrKyxa3uqNXYVQy

# Used for runner and SDB modules
file_/etc/salt/master.d/bitwarden.conf:
  file.exists:
    - name: /etc/salt/master.d/bitwarden.conf
    - require:
        - pip_saltext.bitwarden

# Used for execution, SDB and state modules
file_/etc/salt/minion.d/bitwarden.conf:
  file.exists:
    - name: /etc/salt/minion.d/bitwarden.conf
    - require:
        - pip_saltext.bitwarden

# Make sure the Bitwarden vault is logged in before we start the REST API server
# service otherwise the service will refuse to start
bitwarden_logged_in:
  bitwarden.logged_in:
    - name: logged_in
    - use_cli: True
    - profile: bitwarden

# Run the Bitwarden Vault Management REST API server `bw serve`
service_bw-api:
  service.running:
    - name: bw-api
    - enable: True
    - init_delay: 10
    - require:
        - bitwarden_logged_in

# State to reload systemctl
service_systemctl_reload:
  module.run:
    - name: service.systemctl_reload
    - onchanges:
        - file_/etc/systemd/system/bw-api.service

Nothing in that state restricts access to the API, because nothing needs to: the socket lives in a directory only root and the salt group can traverse. On a TCP port the equivalent takes a set of firewalld direct rules matching on --uid-owner; see Connecting to the API.

The configuration files required vary depending on which module you wish to use:

module type file type
execution minion
runner master
sdb master, minion
state minion

Connecting to the API

bw serve can listen on either a unix socket or a TCP port, and vault_api_url accepts both:

# Unix socket (recommended)
vault_api_url: unix:///run/bw-api/vault.sock

# TCP
vault_api_url: http://localhost:8087

Prefer the socket. The Vault Management API has no authentication of its own: once the vault is unlocked, whatever can reach the API can read every secret in it. On a TCP port that is every process on the host, and the traffic crosses the loopback interface in clear text. A socket is guarded by ordinary filesystem permissions, so access can be restricted to one user or group, and there is no port for anything else to find.

Access is gated by permission to traverse the socket's directory, so put the socket somewhere restricted rather than relying on the mode of the socket file itself, which Node creates from the process umask. Under systemd, RuntimeDirectory= and RuntimeDirectoryMode= do this for you, as in the state above.

Socket paths are limited to about 108 bytes by the operating system, not by this extension or by Bitwarden. A longer path fails at startup with Error: listen EINVAL: invalid argument, which does not name the length as the cause. /run keeps paths comfortably short.

Do not pass --port alongside --hostname unix://.... CLI 2026.6.0 added DNS-rebinding protection, which rejects any request whose Host header is not in an allowlist built from the hostname and port bw serve was given. That check runs for unix sockets too, even though a browser cannot reach one, and the only allowlist entries a socket client can match are the port-based ones. The extension therefore sends Host: localhost:8087, matching the default. Passing --port moves the allowlist and every request comes back 403.

If you are on a Bitwarden CLI older than 2025.9.0, or otherwise need TCP, bind it to localhost and restrict it by owning UID. The Salt firewalld state module does not support direct rules, so the configuration file has to be managed directly:

service_firewalld:
  service.running:
    - name: firewalld
    - enable: True
    - reload: True

file_/etc/firewalld/direct.xml:
  file.managed:
    - name: /etc/firewalld/direct.xml
    - user: root
    - group: root
    - mode: "0644"
    - contents: |
        <?xml version="1.0" encoding="utf-8"?>
        <!--
        ########################################################################
        #                                                                      #
        #              THIS FILE IS MANAGED BY SALT - DO NOT EDIT              #
        #                                                                      #
        # The contents of this file are managed by Salt. Any changes to this   #
        # file may be overwritten automatically and without warning.           #
        ########################################################################
        -->
        <direct>
          <rule ipv="ipv4" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner root -j ACCEPT</rule>
          <rule ipv="ipv4" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner salt -j ACCEPT</rule>
          <rule ipv="ipv4" table="filter" chain="OUTPUT" priority="1">-o lo -p tcp --dport 8087 -j REJECT</rule>
          <rule ipv="ipv6" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner root -j ACCEPT</rule>
          <rule ipv="ipv6" table="filter" chain="OUTPUT" priority="0">-o lo -p tcp --dport 8087 -m owner --uid-owner salt -j ACCEPT</rule>
          <rule ipv="ipv6" table="filter" chain="OUTPUT" priority="1">-o lo -p tcp --dport 8087 -j REJECT</rule>
        </direct>
    - watch_in:
        - service_firewalld

Both transports are exercised by the integration tests against a real bw serve.

Usage

The extension provides read and write access to a Bitwarden vault through execution, runner, state and sdb modules. The execution and runner modules expose the same set of functions; the runner runs them on the master, the execution module on a minion.

Reading and writing items

# Read
salt '*' bitwarden.get_item item_id=2fcd790a-70f7-43a2-b265-08a763873980
salt '*' bitwarden.get_password item_id=2fcd790a-70f7-43a2-b265-08a763873980
salt '*' bitwarden.list_items search="Example Item"

# Write
salt '*' bitwarden.create_folder name="Servers"
salt '*' bitwarden.create_item item='{"type": 1, "name": "Example", "login": {"username": "jdoe", "password": "hunter2"}}'
salt '*' bitwarden.delete_item item_id=2fcd790a-70f7-43a2-b265-08a763873980 permanent=True

Attachments, Sends and organization collections are managed the same way; see the module documentation for the full list.

States

Servers folder exists:
  bitwarden.folder_present:
    - name: Servers

Example login:
  bitwarden.item_present:
    - name: Example Login
    - item:
        type: 1
        login:
          username: jdoe
          password: {{ salt['sdb.get']('sdb://bitwarden/by-uuid/.../password') }}

States support test=True. Secret values are redacted from the changes output by default, because state returns are written to the job cache and the master's logs; set show_secret_changes: True on an individual state if you need to see them.

Declaring secrets in SLS files puts them in your state tree, which is normally version controlled. Prefer rendering them from pillar or an sdb:// URI, or manage only non-secret attributes and generate passwords with bitwarden.generate_password.

Error handling

Failures raise exceptions rather than returning a value. salt.exceptions.SaltInvocationError means the arguments were wrong; subclasses of salt.exceptions.CommandExecutionError (BitwardenNotFoundError, BitwardenAuthenticationError, BitwardenUnavailableError, BitwardenServerError) mean the operation failed. Salt reports these itself, so there is no need to test return values for falsiness.

A note on consistency

bw serve answers from a local cache that it populates asynchronously. An object that was just created is not reliably visible to the very next request, and a read issued immediately after a write can return the pre-write value.

This extension retries once after a sync when an object is unexpectedly missing, and the state modules sync before comparing so they never diff against stale data. If you are scripting your own sequence of calls against the API, call bitwarden.sync between a write and a dependent read.

SDB

# SDB via runner module
salt-run sdb.get 'sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password'
CorrectHorseBatteryStaple
# SDB via execution module
salt-call sdb.get 'sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password'
local:
    CorrectHorseBatteryStaple

As always, you can also reference SDB modules in your pillar files:

example_pillar:
  some_password: {{ salt['sdb.get']('sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password') }}

The format of the SDB URI is as follows:

sdb://<profile>/by-uuid/<uuid>/<object>

Where <profile> is the profile defined in the master or minion configuration file, <uuid> is the UUID of the item, and <object> is one of:

  • name
  • username
  • password
  • totp
  • notes
  • creation_date
  • revision_date
  • deleted_date
  • password_revision_date

Additionally, password history can be retrieved with the following SDB URI format:

sdb://<profile>/by-uuid/<uuid>/password_history/by-index/<index>

Where <profile> is the profile defined in the master or minion configuration file, <uuid> is the UUID of the item, and index is a non-negative integer specifying which password to retrieve from the history (0 being the current password, 1 being the previous password, and so forth).

Lastly, custom fields can be retrieved with the following SDB URI format:

sdb://<profile>/by-uuid/<uuid>/fields/by-name/<field_name>/<object>

Where <profile> is the profile defined in the master or minion configuration file, <uuid> is the UUID of the item, <field_name> is the name of custom field, and object is one of:

  • value
  • type
  • linked_id

Note that the custom field name must be unique within an item. Bitwarden does not enforce unique custom field names, so that is left up to the user.

sdb.set writes to an item's name, username, password, notes or a custom field's value:

salt-run sdb.set 'sdb://bitwarden/by-uuid/2fcd790a-70f7-43a2-b265-08a763873980/password' 'NewCorrectHorseBatteryStaple'

It only sets values within an existing item; use the execution, runner or state modules to create items.

The UUID of an item can be found using the Bitwarden CLI:

bw list items --search "Google Account" --pretty
[
  {
    "object": "item",
    "id": "2fa63ad5-e4e4-43d4-a089-3fadcf455be2",
    "organizationId": null,
    "folderId": null,
    "type": 1,
    "reprompt": 0,
    "name": "Google Account",
    "notes": null,
    "favorite": false,
    "login": {
      "uris": [
        {
          "match": null,
          "uri": "https://accounts.google.com"
        }
      ],
      "username": "user@example.com",
      "password": "aTjSsJvhQY5E24",
      "totp": "AEM1HEESIEV8YAED8THUBEHOOW",
      "passwordRevisionDate": null
    },
    "collectionIds": [],
    "revisionDate": "1970-01-01T00:00:00.000Z"
  }
]

Development

python -m venv .venv && . .venv/bin/activate
pip install -e '.[tests,dev,lint]'
pre-commit install

# Unit tests only -- no vault, no container runtime needed
pytest tests/unit/

# Everything, including integration tests
pytest tests/

The integration tests bring up a Vaultwarden container, register an account against it and run a real bw serve against that account, so they exercise the actual API rather than a mock. They need a container runtime and the bw CLI, and skip themselves cleanly when either is missing.

Vaultwarden is served over TLS with a throwaway self-signed certificate, because current Bitwarden CLI releases refuse to talk to a plain-HTTP server.

Under rootless Podman, point the Docker SDK at Podman's socket:

systemctl --user start podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
pytest tests/

Vaultwarden implements the personal vault faithfully but only part of the organization surface, so organization collections, members and confirmation are covered by unit tests only. Exercising those for real needs a paid Bitwarden organization.

Releasing

Releases are published to PyPI by GitLab CI using PyPI Trusted Publishing, so no API token is stored in the project. GitLab mints a short-lived OIDC token, twine exchanges it for a PyPI token valid for about 15 minutes, and uploads with that.

One-time setup on PyPI, under the project's Publishing settings, add a GitLab publisher:

Field Value
Namespace ggiesen
Repository name saltext-bitwarden
Pipeline filepath .gitlab-ci.yml
Environment release

The environment is optional to PyPI but recommended: pair it with a protected environment in GitLab to require manual approval before an upload runs.

To cut a release:

# 1. Add changelog fragments as you go, under changelog/
#    named <issue>.<type>.md, or +<slug>.<type>.md when there is no issue.

# 2. Fold them into CHANGELOG.md
towncrier build --version X.Y.Z --yes

# 3. Commit, tag, push. Tags are v-prefixed; setuptools_scm strips the v, so
#    the released version is still X.Y.Z.
git commit -am "Release X.Y.Z"
git tag -a vX.Y.Z -m "Release X.Y.Z"
git push && git push origin vX.Y.Z

Pushing the tag runs the pipeline. The publish job only appears for tags matching vX.Y, and it is manual: nothing reaches PyPI until someone runs it, so tagging a release does not auto-fire an upload. It refuses to upload if the built version does not match the tag, which catches a shallow clone or a dirty tree producing a .dev version.

Docs

Module documentation is available at https://ggiesen.gitlab.io/saltext-bitwarden.

Bugs

Bugs can be reported using the Issue Tracker.

Contributing

All contributions are welcome and very much appreciated. Contributing guide coming soon. Contributing can take many forms, including:

  • Reporting bugs
  • Feature requests
  • Code submissions (bug fixes/new features/improve code quality)
  • Writing tests
  • Writing documentation
  • Detailing use cases
  • Writing blog posts

License

This project is licensed under the Mozilla Public License 2.0. See LICENSE for the licence text.

Releases up to and including 0.1.0 were published under the Apache Software License. That grant is not retracted: anyone who obtained those releases keeps their rights under Apache 2.0. MPL 2.0 applies from the next release onwards.

Some of the packaging and tooling in this repository (noxfile.py, docs/conf.py, the .pre-commit-hooks/ scripts and parts of the test scaffolding) originates from the Apache-2.0 licensed salt-extension-copier template. MPL 2.0 is a file-level licence and explicitly allows combining with other terms, so those files may still carry their original licence.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

saltext_bitwarden-0.2.0.tar.gz (111.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

saltext_bitwarden-0.2.0-py2.py3-none-any.whl (51.6 kB view details)

Uploaded Python 2Python 3

File details

Details for the file saltext_bitwarden-0.2.0.tar.gz.

File metadata

  • Download URL: saltext_bitwarden-0.2.0.tar.gz
  • Upload date:
  • Size: 111.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for saltext_bitwarden-0.2.0.tar.gz
Algorithm Hash digest
SHA256 9878f0ea648ac24fd06fb8018cf17c9a2b49369fb0441b07ec8a51082cff68dd
MD5 d43d46d126547d3521689492bb713bed
BLAKE2b-256 89f34bcf84cb1fab76fd091ca4ea3e00bcdb31e1375a457e263775070226013a

See more details on using hashes here.

File details

Details for the file saltext_bitwarden-0.2.0-py2.py3-none-any.whl.

File metadata

File hashes

Hashes for saltext_bitwarden-0.2.0-py2.py3-none-any.whl
Algorithm Hash digest
SHA256 db2660ed9a32a728752d5b47947490af0f6804c8c4bb5b20f7b0f0f886e5e7bc
MD5 a827e71bdffd7d7ead82771d6aa1a1a4
BLAKE2b-256 09f92e0bbe25427f0d9e2f96c180b33da51e6f49ce5b825fadfb563b05dd66d4

See more details on using hashes here.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page