Bitwarden Secrets Manager

Load a secret from Bitwarden Secrets Manager, the machine-account product behind the bws CLI, in pure standard library on top of httpcore. This is a provider for Secrets Manager and not for the consumer password manager: the two speak different APIs and use different key hierarchies.

Schemebitwarden-sm://
Modulegithub.com/xavidop/mamori/providers/bitwarden
Sensitiveyes (always)
Watchpoll
AuthBWS_ACCESS_TOKEN

Install

go get github.com/xavidop/mamori/providers/bitwarden
import _ "github.com/xavidop/mamori/providers/bitwarden" // registers bitwarden-sm://

This module requires Go 1.26: it decrypts values with crypto/hkdf, which only entered the standard library in Go 1.24.

Using the ref

A bitwarden-sm:// ref points at one secret by id, optionally selecting a field from a JSON value stored in it.

bitwarden-sm://<secret-uuid>
bitwarden-sm://<secret-uuid>[#field-or-pointer]
PartRequiredWhat it means
<secret-uuid>yesThe secret’s UUID: the Bitwarden UI’s “Copy secret ID”, and what bws secret get takes.
#field / #/json/pointernoSelect a field from the decrypted value via mamori.SelectKey: a literal top-level key, or an RFC 6901 JSON Pointer for a nested field.

Examples

  • bitwarden-sm://6b3f9e0c-9f9a-4a5c-9a09-af9601317f2d returns the whole decrypted value.
  • bitwarden-sm://6b3f9e0c-9f9a-4a5c-9a09-af9601317f2d#password returns the password field of a JSON secret.
  • bitwarden-sm://6b3f9e0c-9f9a-4a5c-9a09-af9601317f2d#/db/password selects a nested field by JSON Pointer.
type Config struct {
    StripeKey secret.String `source:"bitwarden-sm://6b3f9e0c-9f9a-4a5c-9a09-af9601317f2d"`
    DBPass    secret.String `source:"bitwarden-sm://6b3f9e0c-9f9a-4a5c-9a09-af9601317f2d#password"`
}

A ref takes a UUID, never a secret’s name. Bitwarden’s list endpoint returns each name as ciphertext and omits the value entirely, so resolving by name would mean fetching and decrypting every secret in the organization on every poll, and a name is not unique across projects anyway.

A ref that is not a UUID is invalid, not not_found. It is rejected before any request is sent. not_found is the kind that makes mamori apply a field’s default, so a malformed ref, from an unset ${VAR} in an interpolated one for instance, fails loudly instead of quietly becoming a default value.

Values are always marked Sensitive. Value.Version is the secret’s revisionDate, a plaintext field returned beside the ciphertext, so change detection never touches the decrypted value.

Authentication

A machine account access token, via BWS_ACCESS_TOKEN (the same variable bws reads) or explicitly:

mamori.WithProvider(bitwarden.New(bitwarden.WithAccessToken("0.uuid.secret:key==")))

Self-hosted, and Bitwarden’s EU cloud, deriving both endpoints the way bws does (base + "/identity" and base + "/api"):

mamori.WithProvider(bitwarden.New(bitwarden.WithServerURL("https://vault.bitwarden.eu")))
mamori.WithProvider(bitwarden.New(bitwarden.WithServerURL("https://vault.example.com")))

The token is read lazily, at resolve time, so a blank import is safe even when no token is set at process start. It is re-read on every refresh, so a rotated BWS_ACCESS_TOKEN is picked up without a restart.

Both endpoints must be https://. The exchange posts the client secret in its form body and the API returns the organization’s secrets, so cleartext exposes both. WithAllowInsecure(true) opts into http:// for a local install and permits nothing else, not even a different scheme.

Encrypted values

Bitwarden is end to end encrypted: the API returns ciphertext and the server cannot read it, so this provider performs the client-side unwrap itself. Your access token is stretched into a key that decrypts the organization key out of the token exchange, and the organization key decrypts the secret. None of it is configurable, but two limits follow from it.

A value encrypted as EncString type 7 cannot be read. That variant is a COSE Encrypt0 message carrying XChaCha20-Poly1305, and neither that cipher nor CBOR is in the Go standard library, so such a value reports unavailable naming the missing primitive rather than failing quietly. Type 7 is Bitwarden’s preferred variant for new data, so this is the limitation most likely to matter later.

EncString type 0 is refused rather than decrypted. It is AES-CBC with no MAC, and Bitwarden’s own source marks it as a variant that must never be used to encrypt.

Error classification

Both endpoints classify through httpcore.ClassifyStatus:

HTTP statusmamori kind
400, 422invalid
401unauthenticated
403permission_denied
404not_found
408, 429rate_limited
anything elseunavailable

A rejected token exchange also carries RFC 6749’s error code, such as invalid_client: a fixed token, never free text and never derived from the client secret. The secrets endpoint surfaces no body at all, since its bodies carry secret material. No key, token, ciphertext, or plaintext reaches an error message, and none of them sits in a readable struct field, so none can surface in a %v or %+v dump either.

Errors name the machine account UUID on purpose. It is an identifier, not a credential, and without it a deployment with several machine accounts cannot tell which one failed.

Watch

Secrets Manager exposes no push channel, so mamori polls (interval + jitter). Configure with mamori.WithPollInterval. The access token is cached and refreshed 30 seconds before expiry by default (WithLeeway), and concurrent resolves starting cold share a single exchange rather than one each.

Configuration

OptionEffect
WithAccessToken(token)Machine account access token; empty falls back to BWS_ACCESS_TOKEN
WithServerURL(base)A self-hosted install or the EU cloud, deriving both endpoints from one base
WithIdentityURL(u), WithAPIURL(u)Override one endpoint alone
WithHTTPClient(c)Inject a custom *http.Client for both endpoints
WithMaxBody(n)Cap the response size accepted from either endpoint
WithLeeway(d)How far before its stated expiry a cached token is renewed
WithAllowInsecure(yes)Permit an http:// endpoint, and nothing else

The key derivation and the cipher are checked against test vectors published by Bitwarden, and the rest against an in-process HTTP fake, so the conformance suite runs without a Bitwarden organization. No ciphertext produced by Bitwarden’s own servers has been decrypted by this code at authoring time; a //go:build integration test closes that gap against a real organization when BWS_ACCESS_TOKEN and MAMORI_BWS_SECRET_ID are set, and skips cleanly when they are not.