Contents

Vaultwarden Hardening Beyond the Defaults

The container works out of the box, which is the problem

Contents

Vaultwarden is a triumph of scope discipline: a Rust reimplementation of the Bitwarden server that fits in a 100MB container, runs on a Raspberry Pi, and speaks the protocol well enough that the official clients cannot tell. It has replaced a hosted subscription for a lot of people, including me, and the project deserves the reputation it has.

It also starts up with a configuration that assumes you know what you are doing next. The first-run defaults exist to get you to a working login screen, and several of them are actively wrong for a thing that holds every credential you own. The basic setup is a compose file and ten minutes. This is what comes after, and it matters more, because the failure modes of a password manager are unusually unforgiving.

Two principles frame all of it. First: the vault data is encrypted client-side with a key derived from your master password, so the server never sees plaintext and a stolen database is a slow offline cracking problem rather than an instant catastrophe. Second: that guarantee is only as good as your KDF settings and your master password, and everything else here is about the surface around it.

Turn off signups before you do anything else

Advertisement

SIGNUPS_ALLOWED defaults to true. A freshly deployed Vaultwarden reachable from the internet is an open registration form, and open registration on a password manager means anyone can create an account, which means they can enumerate whether an email is registered, consume your storage, and — depending on your version and configuration — see organisation names. This is the number-one misconfiguration and it is one environment variable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
services:
  vaultwarden:
    image: vaultwarden/server:1.32.7-alpine
    restart: unless-stopped
    environment:
      # Nobody registers unless I say so.
      SIGNUPS_ALLOWED: "false"
      SIGNUPS_VERIFY: "true"
      INVITATIONS_ALLOWED: "true"
      SIGNUPS_DOMAINS_WHITELIST: "example.com"

      # Stop the login form confirming which emails exist.
      SHOW_PASSWORD_HINT: "false"

      # Kill the /admin panel entirely; see below.
      DISABLE_ADMIN_TOKEN: "false"

      DOMAIN: "https://vault.example.com"
      ROCKET_PORT: "8080"
    volumes:
      - ./data:/data
    networks:
      - edge
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

Create your own account, then set SIGNUPS_ALLOWED: "false" and add everyone else by invitation. SIGNUPS_DOMAINS_WHITELIST is a useful second line if you want family members to self-serve without opening the door to the world.

SHOW_PASSWORD_HINT deserves its own sentence. With it on, the login form will tell an anonymous requester whether an address has an account — and in some configurations mail the hint itself. That is a free user-enumeration oracle on the most sensitive service you run.

The admin panel is a backdoor with a password

/admin lets you change every setting, delete users, and inspect the configuration, and it is gated by a single shared token. There is no second factor. There is no per-user identity. If that token leaks or is guessed, the attacker owns the deployment.

The token must be an argon2 hash rather than a plaintext string. Vaultwarden has supported this for a while and still accepts plaintext, which people therefore keep using:

1
2
3
4
5
6
# Generate the hash. Note the single quotes when you paste it —
# argon2 output contains $ and your shell will happily eat it.
docker run --rm -it vaultwarden/server:latest /vaultwarden hash --preset owasp

# Produces something like:
# $argon2id$v=19$m=19456,t=2,p=1$Rm9vQmFyQmF6$k3Xy...

In compose, that value has to survive variable interpolation, which means doubling every $:

1
2
    environment:
      ADMIN_TOKEN: "$$argon2id$$v=19$$m=19456,t=2,p=1$$Rm9vQmFyQmF6$$k3Xy..."

Better still, decide you do not need /admin at all. Everything it does can be done with environment variables in a compose file you keep in git, which is a superior arrangement anyway because the configuration is then reviewable and reproducible. Set DISABLE_ADMIN_TOKEN: "true" to remove the panel completely, or block the path at your proxy so it exists only from your management network:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
vault.example.com {
	@admin path /admin*
	handle @admin {
		@internal remote_ip 192.168.1.0/24
		handle @internal {
			reverse_proxy vaultwarden:8080
		}
		respond 404
	}
	handle {
		reverse_proxy vaultwarden:8080
	}
}

Return 404 rather than 403 there. A 403 confirms the path exists.

KDF settings are the actual security boundary

Advertisement

This is the one that people miss, because it lives in the client rather than the server and nothing prompts you about it.

Your vault is encrypted with a key stretched from your master password by a KDF. If someone steals the database, the only thing standing between them and your plaintext is how expensive it is to try each password guess. Vaultwarden inherits Bitwarden’s default of PBKDF2 at 600,000 iterations, which is defensible and is also the weakest option available to you.

Switch to Argon2id. In the web vault, under Account Settings → Security → Keys, change the KDF and set the parameters deliberately:

  • Memory: 64 MB or more. This is the number that matters. PBKDF2’s weakness against GPUs is that it needs almost no memory, so an attacker rents a rack of cards and runs thousands of guesses in parallel. Argon2id at 64MB means each parallel guess needs 64MB of VRAM, and a 24GB card suddenly manages a few hundred rather than tens of thousands.
  • Iterations: 3.
  • Parallelism: 4.

Every client has to re-derive on unlock, so there is a real cost: an old phone will take a noticeable second. That is the trade, and it is one-sided in your favour. Changing the KDF logs out every session and re-encrypts the vault, so do it once, deliberately, with a good backup in hand.

While you are in there: your master password should be a passphrase of five or six random words from a real generator. Argon2id at 64MB buys you perhaps a few orders of magnitude against a cracking rig. A weak master password gives away far more than that.

Do not put it on the public internet

Vaultwarden’s own documentation is careful about this and people ignore it. The mobile clients need to reach the server, so everyone reflexively publishes it on a hostname and puts a proxy in front. That is an internet-facing attack surface on your credential store, defended by whatever CVE has not been found yet.

The better arrangement is a mesh VPN. Put Vaultwarden on a Tailscale address, install the client on the phones that need it, and the service has no public surface at all. The Bitwarden clients neither know nor care; they see a hostname that resolves and a certificate that validates. Battery cost on a modern phone is negligible and the entire class of “someone found a bug in the login endpoint” disappears.

If you genuinely must expose it — a family member who will not tolerate a VPN client — then put an identity layer in front. Authelia or Authentik will demand a second authentication before a request reaches Vaultwarden at all. Note that the mobile apps will then need a bypass or a client certificate, which is fiddly, and the fiddliness is a signal about which choice is right.

Turn on the built-in rate limiting either way, since it costs nothing:

1
2
3
4
      LOGIN_RATELIMIT_SECONDS: "60"
      LOGIN_RATELIMIT_MAX_BURST: "5"
      ADMIN_RATELIMIT_SECONDS: "300"
      ADMIN_RATELIMIT_MAX_BURST: "3"

And point CrowdSec or fail2ban at the logs. Vaultwarden helpfully logs failed attempts with source IPs in a format both tools can parse; there are maintained parsers for it.

Second factors, and the one that is a trap

Enable 2FA on every account. Vaultwarden supports TOTP, WebAuthn/FIDO2 and Duo. A hardware key via WebAuthn is the strongest, is phishing-resistant, and works on desktop and modern phones.

The trap is email 2FA. It is available, it is convenient, and it makes your vault only as strong as your email account — which is very often protected by a password stored in the vault. Consider that loop carefully before enabling it for anyone.

The second trap is storing your Vaultwarden TOTP seed in Vaultwarden. The vault will happily hold the TOTP code for its own login, and everything works beautifully until the day you need to log in from a device that does not have the vault yet. Keep that one seed somewhere else.

Backups, and the restore you have not tested

Advertisement

Vaultwarden is SQLite, and copying a live SQLite file gives you a corrupt SQLite file often enough to ruin your year. Use the backup API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F)
OUT=/var/backups/vw

docker exec vaultwarden \
  sqlite3 /data/db.sqlite3 ".backup '/data/backup-$STAMP.sqlite3'"

# The database is not the whole story.
tar -C ./data -czf "$OUT/vw-$STAMP.tar.gz" \
  "backup-$STAMP.sqlite3" rsa_key.pem attachments/ config.json

docker exec vaultwarden rm "/data/backup-$STAMP.sqlite3"
restic -r "$RESTIC_REPO" backup "$OUT/vw-$STAMP.tar.gz"

rsa_key.pem is the piece everyone forgets. Restore the database without it and every user is logged out permanently with no path back. It changes rarely, which is exactly why it drops out of backup scripts written in a hurry.

Then rehearse the restore. Bring the archive up in a fresh container on a spare port, log in, read a password. The restore you never rehearsed is a special problem here, because the thing you need in the disaster is the thing that holds the credentials for fixing the disaster. Every client also holds an offline copy of the vault, which is a real safety net worth knowing about — and it means a laptop that has not synced since the compromise still has your old passwords, which is the same fact wearing a different hat.

Emergency access, and the family problem

If you have moved a household onto your Vaultwarden, you have taken on a responsibility that a hosted service quietly handles: what happens to those vaults when you are unavailable.

Vaultwarden implements Bitwarden’s emergency access. A user nominates a trusted contact and a waiting period; the contact can request access, the user gets an email and can refuse, and if nobody refuses within the waiting period the contact is granted view or takeover rights. The cryptography is sound — the vault key is wrapped to the contact’s public key at the moment of nomination, so the server never holds anything useful.

1
2
3
4
5
      EMERGENCY_ACCESS_ALLOWED: "true"
      SMTP_HOST: "smtp.example.com"
      SMTP_FROM: "[email protected]"
      SMTP_SECURITY: "starttls"
      SMTP_PORT: "587"

The dependency to notice: emergency access needs working email, and the notification is the only thing standing between a request and a grant. Configure SMTP properly or the feature is a silent 7-day countdown that nobody hears. Test it by sending yourself an invitation before you rely on it.

The bigger version of the same problem is that the server is a single point of failure with your name on it. Write down, on paper, in a place your family can find: where the server is, what the backups are called, and how to restore them. Every client holds an offline copy of its own vault, so nobody is instantly locked out — but “export your vault to a file before you need to” is advice worth giving your household once a year. It costs nothing and it converts your unavailability from a crisis into an inconvenience.

Keeping the container honest

The compose snippet above quietly includes read_only, cap_drop: ALL and no-new-privileges. Vaultwarden runs fine under all three, which is unusual and speaks well of the project. It needs a writable /data and a tmpfs for /tmp, and nothing else.

Two more worth doing. Pin the tag. The example says 1.32.7-alpine rather than latest deliberately: Vaultwarden’s database migrations run automatically on start and they are one-way. An accidental jump across a major version with a corrupt backup is a genuinely bad afternoon, and latest is how that happens at 3am when the host reboots and pulls. Pin it, read the release notes, update on purpose.

Give it its own network. The container needs to talk to your proxy and to SMTP. It has no business reaching the rest of the lab, and an egress restriction costs one line:

1
2
3
networks:
  edge:
    external: true

with no other network attached. If something later compromises the container, it lands somewhere boring.

Watch the release notes rather than automating the upgrade. This is the one service where I want to read what changed before it changes.

Troubleshooting

Mobile app: “Failed to fetch” or an endless spinner. Ninety per cent of the time DOMAIN does not exactly match the URL the client uses, including scheme and any port. Vaultwarden builds URLs from it and a mismatch breaks WebAuthn and attachments in confusing ways.

Push notifications never arrive. They require Bitwarden’s own push relay and PUSH_ENABLED with an installation ID and key from the Bitwarden portal. Without it, clients poll, which is fine but means a password added on your desktop is not on your phone for a few minutes.

Websocket errors in the browser console. Since 1.29 the notifications socket is served on the same port and needs the proxy to upgrade /notifications/hub. Most modern proxies do this automatically; old copy-pasted nginx configs from 2021 do not.

/admin returns 404 and you did not mean it to. Either DISABLE_ADMIN_TOKEN is true, or ADMIN_TOKEN is empty — an unset token disables the panel by design. If you set an argon2 hash and the panel rejects your correct token, your $ signs got interpolated. Check the running container’s environment.

Everyone logged out after a restore. rsa_key.pem was not in the backup, or is from a different deployment. There is no recovery beyond every user logging in again with their master password, which they may not remember, because it is in the vault.

The verdict

Self-hosted Vaultwarden is a genuinely good idea and I would not go back. It is small, it is fast, the clients are excellent, and the client-side encryption means the server is a far less interesting target than instinct suggests.

Do these five things and stop: signups off, admin panel gone or hidden, Argon2id at 64MB, no public exposure, and a backup that includes rsa_key.pem and has been restored at least once. That is an afternoon and it moves you from “works” to “defensible”.

Where I would tell you to stay hosted: if you have never restored a backup in your life, and you are not going to start now. A hosted Bitwarden account costs about ten pounds a year and someone else worries about the disk. The failure mode of a badly run self-hosted password manager is losing every credential you own at the worst possible moment — and if that sentence made you uncomfortable, that discomfort is the correct signal to act on.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.