SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Is Vaultwarden Secure? A Hardening Pass

Vaultwarden encrypts vault contents in the client, but stores account metadata in plaintext. The real risks are your admin token and your backup file.

Is Vaultwarden secure? The short answer

Vaultwarden is secure in the one place that matters most, because every vault item is encrypted on your device before it reaches the server. The server stores encrypted vault contents alongside plaintext account metadata. Someone who copies the whole database still needs the vault's encryption keys to read its contents, but gets account email addresses and password hints immediately.

That answer is doing a lot of work, and the parts that break are the parts you configure. An admin panel behind a guessable token. A container port published to the whole internet. A plaintext config.json. A backup tarball sitting in a home directory on the same box. None of those are cryptography problems. All of them are the reason self-hosted vaults get emptied.

Everything below assumes a working install. If you do not have one yet, set it up with the Vaultwarden install guide for a VPS first, then come back and work down this list in order.

What the server actually stores

Vaultwarden implements Bitwarden's data model. A vault item's name, username, password, notes and URIs are encrypted in the client with a randomly generated item key before any request is sent. That key is protected by the user or organisation symmetric key; the password-derived key protects the user's symmetric key. Attachment file contents use a separate attachment key protected by the item key. The server receives opaque data with a UUID (universally unique identifier) attached to it.

Some things are not ciphertext, and you should know exactly which:

  • Your account email address and master password hint, in plaintext.
  • Your KDF (key derivation function) settings and salt, because the client needs them to rebuild the key on the next login.
  • A server-side hash of the master password hash the client sends, used to authenticate the login itself.
  • Metadata: organisation membership, device names, last login times.
  • The TOTP secret for authenticator-based two-factor login. It lives in the twofactor table unencrypted, because the server has to compute the expected code to compare against yours. This is not the same thing as a TOTP (time-based one-time password) secret you store inside a vault item, which is encrypted like any other field.

The data folder is small. On a Docker install it is whatever you mounted at /data.

sudo ls -l /vw-data/

db.sqlite3 holds almost all state. attachments/ holds uploaded files under UUID directories, outside the database tables. sends/ holds Send attachments and is meant to be temporary. icon_cache/ is throwaway. rsa_key.pem and its companions sign the JWTs (JSON web tokens) of logged-in users, so a copy of that private key can be used to forge a vault login session. config.json stores settings saved through the admin page, including SMTP credentials in plaintext and the configured admin token value. Following the Argon2 instructions below stores an admin token hash rather than the plaintext password.

So the practical threat model is filesystem access, not network cryptography. Read access to that one directory hands over every user's email address, their login 2FA secrets, a key that forges sessions, and an offline copy of every vault to attack at leisure. Every step below exists to keep people out of that directory.

Fix the admin token first

/admin is a full control panel: user list, invitations, deletion, every runtime setting. It is protected by one shared secret and nothing else. No username. No per-user two-factor.

Older guides tell you to generate ADMIN_TOKEN with openssl rand -base64 48. That works, but leaves the secret in plaintext wherever you configure it. Vaultwarden also accepts an Argon2 PHC (password hashing competition) string, so the stored value is a hash instead. Generate one against a running container:

docker exec -it vaultwarden /vaultwarden hash

Or without touching the running container at all:

docker run --rm -it vaultwarden/server /vaultwarden hash

It asks for a password twice, then prints a line beginning with $argon2id$. On a bare-metal install, run ./vaultwarden hash. If you would rather use the argon2 CLI directly, the project documents the OWASP minimum parameters:

echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1

Now the trap that costs people an hour. A PHC string is full of $ characters, and Docker Compose treats $ as variable interpolation. Paste it unescaped into an environment: block and the value that reaches the container is mangled, so /admin rejects a token you know is correct. Two safe forms. In docker-compose.yml, double every $:

environment:
  ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UI

In an .env file, no escaping is needed, but use single quotes:

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'

Then rate limit the panel and shorten its session:

ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=20

This allows a burst of three admin login requests per client IP, then replenishes the allowance at one request every five minutes. Correct and incorrect passwords both count, because the limiter runs before the password check. Requests over the limit receive HTTP 429 with Too many requests, try again later. The admin session expires 20 minutes after you log in, whether or not you are still using the panel, because activity does not extend it.

Better than any of this: turn the page off. Most instances need it once, to configure SMTP and invite the first users, and never again. To disable it, set neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. Deleting the key from the file matters because the admin page writes settings there, and what is in config.json wins over the environment. Removing the variable alone leaves the page open.

Close registration before anyone finds the domain

SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=false

SIGNUPS_ALLOWED defaults to true. Leave it that way and anyone who reaches your domain gets an account, and their data sits in the same db.sqlite3 as yours. Set it to false and add people through invitations from the admin page; SMTP delivers the invitation email, while without it the server records an invitation for that address. INVITATIONS_ALLOWED is also true by default and lets organisation admins invite others. That is fine when you trust your users, and it should be false on a single-user instance. If only certain domains should ever register, SIGNUPS_DOMAINS_WHITELIST=example.com is narrower than open signup and much weaker than invitations.

SHOW_PASSWORD_HINT is false by default and should stay there. With it on and no SMTP configured, typing a valid email address into the login form returns that account's master password hint, which both leaks the hint and confirms the address exists.

If your instance ran with signups open for any length of time, open the admin page and read the user list before assuming you are the only account on it.

The port you did not mean to publish

The Docker image listens on port 80 inside the container. A bare-metal install defaults to ROCKET_PORT=8000. The documented run command publishes it like this:

--publish 127.0.0.1:8000:80

The 127.0.0.1: prefix is the entire point. Write -p 8000:80 instead and Docker binds 0.0.0.0, and it does that by writing DNAT (destination network address translation) rules into the nat table. Those rules are evaluated before the filter chains that ufw manages, so ufw status reports the port as denied while the port cheerfully answers the internet. The full mechanism is worth reading in the guide to Docker ports bypassing ufw.

Check what is really listening:

sudo ss -tlnp | grep 8000

A healthy result is a single line bound to 127.0.0.1:8000. A line bound to 0.0.0.0:8000 means the vault is exposed directly. Fix the mapping, then recreate the container, because a port binding is fixed when the container is created and docker compose restart will not change it:

docker compose up -d --force-recreate

One more port survives in old guides: 3012, the separate WebSocket port. Support for it was removed in Vaultwarden 1.31.0, because notification traffic moved onto the main HTTP port. WEBSOCKET_ENABLED and WEBSOCKET_PORT have been deprecated since 1.29.0 and are ignored. The current switch is ENABLE_WEBSOCKET, which defaults to true. If your firewall or compose file still opens 3012, close it.

Terminate TLS at a reverse proxy, not in Rocket

Vaultwarden can serve TLS (transport layer security) itself through Rocket, its web framework, and the project tells you not to in production. Rocket's built-in TLS lacks strict SNI (server name indication) support, which is also why the hardening advice is to reach your instance by hostname and never by bare IP address. Public IP ranges are scanned constantly, and a vault that answers on an IP address is a vault that gets found.

The parts of an nginx server block that matter:

client_max_body_size 525M;

location / {
  proxy_pass http://127.0.0.1:8000;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
}

nginx defaults client_max_body_size to 1m, so without an override only request bodies larger than that limit fail. nginx returns HTTP 413 to the client and logs client intended to send too large body: ... bytes; the rejected upload does not reach Vaultwarden. The Upgrade and Connection headers carry the WebSocket handshake to /notifications/hub. Drop them and the vault still works, but WebSocket notifications stop reaching browser and desktop clients; mobile clients use separately configured push notifications.

Caddy is shorter and obtains the certificate on its own:

vw.example.com {
  reverse_proxy 127.0.0.1:8000 {
    header_up X-Real-IP {remote_host}
  }
}

Then tell Vaultwarden about it:

DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IP

IP_HEADER already defaults to X-Real-IP, so the job is making sure the proxy actually sets that header. Vaultwarden also accepts the header only from addresses listed in IP_HEADER_TRUSTED_PROXIES, which defaults to local: any non-global address, which covers a proxy on the same host or the same container network. A proxy that connects from a public address has to be added there. If the header is missing or comes from an untrusted address, every log line and every login rate limit sees the proxy's own address, which means one attacker's failures are counted against every user on the instance. If you use IP_HEADER=X-Forwarded-For instead, 1.37.4 takes the rightmost address that is not a trusted proxy, so list every proxy in the chain, a CDN in front of nginx included. Set DOMAIN to the real https URL too, because Vaultwarden builds invitation and account recovery links from it, and WebAuthn security keys are bound to that origin.

One detail people miss: the WebSocket connection passes the session token in the query string, as /notifications/hub?access_token=[JWT]. That lands in your proxy access log in the clear. Redact the access_token parameter in the log format, or make sure those logs are not shipped anywhere you do not control.

Ban brute force at the login endpoint

Rate limits are on by default (LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). They slow an attacker down. They do not stop one. fail2ban does, but it needs Vaultwarden's failed logins somewhere it can read them. In Docker the simple route is a log file, and Vaultwarden does not write one out of the box:

LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=true

A failed password login produces a line in this form, and this is the string your filter has to match:

[2026-08-07 09:14:22.000][vaultwarden::api::identity][ERROR] Username or password is incorrect. Try again. IP: 203.0.113.10. Username: user@example.com.

Write the filter to /etc/fail2ban/filter.d/vaultwarden.local:

[INCLUDES]
before = common.conf

[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =

And the jail to /etc/fail2ban/jail.d/vaultwarden.local:

[vaultwarden]
enabled = true
backend = pyinotify
port = 80,443
filter = vaultwarden
banaction = %(banaction_allports)s
logpath = /vw-data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400

If Vaultwarden runs as a systemd service instead of in a container, you can skip LOG_FILE: the project's fail2ban wiki reads the journal with backend = systemd and a journalmatch on the Vaultwarden unit in place of logpath.

If you kept the admin page, add a second jail whose failregex is ^.*Invalid admin token\. IP: <ADDR>.*$, because admin failures are logged with a different message and the login filter will never see them. Then check your work:

sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden

A working jail lists your log file under File list and reports Currently failed: 0. Type a wrong password three times from a different network and that counter rises, then the address appears under Banned IP list. If the counter never moves, the usual cause is logpath: it must be the file's path on the host, not the /data/... path inside the container. The second usual cause is a missing or untrusted X-Real-IP, which makes every ban target your own proxy. The rest of the setup, including the SSH jail you should already be running, is in the fail2ban guide for Ubuntu 24.04.

The master password is still the whole system

Client-side encryption means the master password protects the user's symmetric key. A short master password on an instance whose database an attacker has copied is protected by nothing in this post, because they attack that copy offline at whatever rate their hardware allows. No server setting reaches an attacker's own machine.

PASSWORD_ITERATIONS=600000 is easy to misread. It sets the iterations for the server-side hash of the login hash the client sends, and the project's configuration template says a changed value is applied to existing users at their next login. It does not touch the client-side KDF, which is the setting that decides how expensive an offline attack on the vault is. That one belongs to each account, and it is changed under Settings, Security, Keys in the web vault, which re-encrypts the key. Bitwarden's documentation says its clients since 2026.2.1 raise a PBKDF2 setting below 600,000 iterations automatically when the user logs in or unlocks with the master password. As of 10 October 2026 that behaviour is not confirmed against a Vaultwarden server, and it never changes Argon2id settings. So someone who signed up years ago may still be on an older, lower KDF setting. Check, and tell them, because the server cannot do it for them.

Then enable two-factor authentication per account. It does not protect the ciphertext, since it does not participate in vault encryption. It does stop a stolen password from being enough to log in and sync a copy. With email configured, REQUIRE_DEVICE_EMAIL=true makes the new-device login email mandatory: if sending fails, the login fails. If the mail service is disabled entirely, this setting does not block new-device logins.

Backups are where self-hosted vaults go wrong

A tar czf of the data folder, left in a home directory on the same VPS, undoes every step above. That archive holds db.sqlite3 with every user's ciphertext, rsa_key.pem which forges login sessions, and config.json with the configured admin token value and SMTP password. Following the Argon2 instructions stores a hash of the admin password; the SMTP password remains plaintext. Read access to that one file exposes account metadata, server secrets and encrypted vault data.

Two rules cover it. Get the archive off the box. Encrypt it before it leaves.

There is also a correctness problem. Copying db.sqlite3 with cp while the service is running can produce a file that is mid-write and will not open, and you will not find out until the restore. Use SQLite's own snapshot instead:

sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"

The restore side, which is the half nobody tests, is covered in the Vaultwarden backup and restore guide.

What you give up next to hosted Bitwarden

Honest accounting. Bitwarden's hosted service is operated by people whose full-time job is operating it, with published third-party audits. Self-hosting swaps that for your own patch cadence.

Vaultwarden ships security fixes as ordinary releases, and they come often. Checked on 2 November 2026 against the project's release feed, the current release is 1.37.5, published 1 November 2026. Its notes cover two Medium security advisories and strongly advise updating as soon as possible. It is also required for web vault 2026.7.1 and newer to turn two-step login methods on or off, and for 2026.9.0 and newer to take over an account through emergency access. The previous release, 1.37.4, published 5 October 2026, covered seven security advisories, the most severe rated High. It was the fourth release since 1.37.0 on 24 July 2026: 1.37.1 followed on 29 July, 1.37.2 on 22 August (required for clients version 2026.8.0 and newer), and 1.37.3 on 13 September, which revokes 2FA remember tokens when credentials or 2FA change and rate limits the prelogin and auth request endpoints. If you run organisations with admins you do not fully trust, the 1.37.4 notes also suggest rotating each organisation's API key after updating. 1.37.4 also changes which address is read from X-Forwarded-For, covered in the reverse proxy section above. An instance you set up a year ago and forgot is running year-old code. The latest tag does not help on its own: a running container keeps the image it started with until you run docker compose pull and recreate it. Put unattended upgrades on Ubuntu in place for the host packages, and put the container update on a calendar reminder you will actually read.

The conclusion an honest reader should draw: the cryptography here is Bitwarden's design and it holds up, while the operational risk moves entirely onto you. If you patch it and you back it up somewhere else, a Vaultwarden instance on a VPS you control is a reasonable place to keep your passwords. If those two habits are not going to happen, pay for the hosted service and spend the attention elsewhere. The feature-by-feature comparison is in Vaultwarden compared with self-hosted Bitwarden.

Harden the host underneath the container

Vaultwarden is one process on a Linux box, and root on that box can read /vw-data no matter what the application is configured to do. Run the container as an unprivileged user with user: "1000:1000" in your compose file, with the data folder owned to match, and mount anything the container does not write to as read only with :ro. Then close the front door: SSH hardening on a VPS covers key-only login and disabling password authentication, which is what stops the boring attack that gets past all of the above.

FAQ

Can someone read my passwords if they steal the Vaultwarden database?

Not directly. Every vault item is encrypted in the client with a randomly generated key, protected through the user or organisation symmetric key; the password-derived key protects the user's symmetric key. db.sqlite3 contains the encrypted vault contents alongside plaintext account metadata. What they get immediately is each account's email address and password hint, the KDF settings, login and device metadata, and the login TOTP secrets in the twofactor table, which are stored unencrypted because the server must compute the expected code. They can also attack the vault ciphertext offline for as long as they like, which is why master password length is the number that decides the outcome.

Should I use ADMIN_TOKEN or disable the admin page completely?

Disable it if you can, since most instances need it once to configure SMTP and invite users and never again. To disable it, set neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. Removing only the environment variable is not enough, because settings written by the admin page live in config.json and take precedence. If you do keep the page, store the token as an Argon2 hash produced by vaultwarden hash rather than a plaintext random string, and set ADMIN_RATELIMIT_MAX_BURST=3.

My ADMIN_TOKEN is correct but /admin rejects it. What is wrong?

Almost always $ interpolation. An Argon2 PHC string contains several $ characters, and Docker Compose expands them as variables inside a docker-compose.yml environment: block, so the container receives a mangled value while your file looks right. Double every $ to $$ in the compose file, or move the value into an .env file wrapped in single quotes, where no escaping is needed. Recreate the container afterwards, since environment changes are not picked up by a restart.

Do I still need to open port 3012 for notifications?

No. Support for WebSocket traffic on port 3012 was removed in Vaultwarden 1.31.0 because notifications moved onto the main HTTP port, and WEBSOCKET_ENABLED and WEBSOCKET_PORT have been deprecated since 1.29.0 and are ignored. The current setting is ENABLE_WEBSOCKET, which is true by default. Close 3012 in the firewall and delete it from your compose file, then make sure your reverse proxy forwards the Upgrade and Connection headers, because browser and desktop WebSocket notifications depend on them. Mobile clients use separately configured push notifications.

Was this article useful?