SSH key cipher with `null` members in `sshKey` is accepted and silently discarded (Bitwarden cloud rejects the same request)

Author: ashebanowCreated Jul 29, 2026Updated Sep 6, 2026
Labelsbug

Prerequisites

Vaultwarden Support String

n/a - I'm not actually a vaultwarden user, discovered this only while debugging a test script submitted to me.

Vaultwarden Build Version

1.37.0

Deployment method

Official Container Image

Custom deployment method

Discovered this issue while testing https://github.com/cachix/secretspec/pull/166.

Summary

Creating an SSH key cipher (type: 5) whose sshKey object has null in any of privateKey, publicKey or keyFingerprint succeeds against Vaultwarden — the API returns a cipher id — but the key material does not survive the round trip. Reading the item back shows the sshKey object gone.

Bitwarden's own server rejects the identical request with a validation error, so a client that is correct against cloud gets a success response and silent data loss against Vaultwarden. That asymmetry is the actual problem: the write looks like it worked.

Additional context

  • Non-empty strings are all that is required. We tested three payload shapes against both servers: (A) nulls in two members, (B) arbitrary non-empty placeholder strings in all three, (C) realistic-looking OpenSSH key material in all three. Cloud refuses A and accepts both B and C. So this is a presence check, not key-material validation — the fix on the server side is a null/type check, not a parser.

  • The first-party clients do not hit this. The web vault and browser extension always populate all three members, so this only reaches API and CLI clients that build the cipher JSON themselves. That is probably why it has gone unnoticed.

  • We only tested type: 5. Whether other cipher types silently drop their type-specific sub-object when a member is null is untested and worth a look while the relevant code is open — if the cause is generic handling of the type-specific data blob rather than something SSH-specific, the same shape of bug likely exists elsewhere.

  • Where we would start looking (a guess, offered only as a starting point — we did not read the Vaultwarden source): the cipher create/update path in src/api/core/ciphers.rs, where the incoming type-specific data is stored. If members are copied through without a per-type required-field check, a null would land in the stored blob and later collapse the object. Upstream Bitwarden appears to validate at the model-binding layer, which matches the shape of the error message it returns.

  • Reported by the SecretSpec Bitwarden provider work (https://github.com/cachix/secretspec, PR #166), which uses a disposable Vaultwarden container as its CI backend precisely because it needs no real vault credentials. Worked around it client-side by never emitting a null: unaddressed members are initialised to a placeholder string. That workaround is fine for us and we are not blocked — filing this because the silent-success behaviour will bite other API clients the same way, and it cost us a debugging session to find.

Reverse Proxy

n/a

Host/Server Operating System

Linux

Operating System Version

the host OS for the docker image was mac/podman, but not relevant for this case

Clients

CLI

Client Version

CLI 2025.11.0

Steps To Reproduce

Against a Vaultwarden instance:

export BW_SESSION=$(bw unlock --raw)

ID=$(jq -nc '{
  type: 5,
  name: "vw-sshkey-null-probe",
  notes: "probe, safe to delete",
  sshKey: {
    privateKey: "probe-private-value",
    publicKey: null,
    keyFingerprint: null
  },
  fields: []
}' | bw encode | bw create item --nointeraction | jq -r '.id')

echo "created: $ID"

bw sync --nointeraction
bw get item "$ID" --nointeraction | jq '.sshKey'

bw delete item "$ID" --nointeraction

Then run exactly the same script against https://vault.bitwarden.com.

Against a Vaultwarden instance:

export BW_SESSION=$(bw unlock --raw)

ID=$(jq -nc '{
  type: 5,
  name: "vw-sshkey-null-probe",
  notes: "probe, safe to delete",
  sshKey: {
    privateKey: "probe-private-value",
    publicKey: null,
    keyFingerprint: null
  },
  fields: []
}' | bw encode | bw create item --nointeraction | jq -r '.id')

echo "created: $ID"

bw sync --nointeraction
bw get item "$ID" --nointeraction | jq '.sshKey'

bw delete item "$ID" --nointeraction

Then run exactly the same script against https://vault.bitwarden.com.

Observed

Vaultwardenbw create item exits 0 and prints a cipher with an id. After bw sync, the item is present and correctly named, but the key is not there: .sshKey reads back as null. Driving the same write through an application layer, the user-visible symptom is a write that reports success followed by a read that returns [error: cannot decrypt].

Note for whoever picks this up: our notes record both sshKey: null and [error: cannot decrypt] from this configuration. We did not pin down which read path produces which — plausibly the raw bw get item shows the null while the CLI renders a partially-populated, undecryptable object as the error string. Worth re-measuring precisely; it does not change the headline, which is that the write is accepted and the key is unrecoverable.

Bitwarden cloud — the create is refused outright, no cipher is created, and the server's reason is:

invalid type: unit value, expected a valid string

Expected Result

Reject the request, as cloud does. A 400 naming the offending member would be ideal; anything other than 200-plus-discard is an improvement. The failure mode to avoid is the current one, where the only way a client can discover the write did not take is to read the item back and compare.

Actual Result

See reprooduction steps - the short version is the vaultwarden server fails to create the item but returns a 200 response code with an empty response.

Logs

Screenshots or Videos

No response

Additional Context

No response